0%

如何证明地球是圆的?

也许有人会说:飞到天上去看一眼不就行了。

这是个好方法,但这是三维世界生物的思考方式,如果是一个二维世界的生物,它又如何知道自己的世界是平的还是圆的呢?

三角形的内角之和为180°,这是中学生都知道的理论。现在想象一下,你在一张平铺的A4纸上画了一个三角形,又在一个大西瓜上画了一个三角形。毋庸置疑,A4纸上的三角形内角和为180°,那么西瓜上的三角形呢?😁

是的,球形曲面上的三角形内角和大于或小于180°,反过来说,如果你画了一个三角形并测量它的内角和不等于180°,那这个三角形必然处于曲面中。

好了,理论上我们可以在北极取一个顶点,在赤道上取两个顶点,绘制出一个超大三角形,从而证明地球不是方的。

问:如何证明三维空间被扭曲了?

生活在三维世界的人类,用三角形证明地球是圆的,可能没什么意义。但我们却可以用此方法来检测空间是否被扭曲了。

我们的太阳如此庞大,从理论上来说,它对周围的空间必然产生了强大的扭曲,怎么证明呢?

我们可以在3月份和8月份观测太阳“身后”某一颗遥远的星星,再把地球两次观测时间点的轨道位置作为两个顶点,与星星绘制成一个很大很大很大的三角形,然后计算其内角和。

事实证明,这个内角和小于180°——星光经过太阳附近时,和时空一同被扭曲了。

世界有多小

我们曾觉得细菌很小,殊不知细菌由更渺小的细胞构成;
我们曾觉得细胞很小,殊不知细胞内还有更小的DNA;
我们曾觉得DNA很小,殊不知DNA由更小的分子结合而来;
我们曾觉得分子很小,殊不知分子由原子构成;
我们曾觉得原子已经最小了,没想到还有原子核、电子…

中学化学物理课中学到过,电子带有负电荷,原子核带有等量的正电荷,电子围绕的原子核转圈圈,彼此平衡。可事实的真相却是…

原子核内部有一部分带电,另一部分不带电,经过实验得知原子核内核其实还分为:带正电的质子,和不带电的中子。

后来科学家们又发现,宇宙中还存在带正电荷的正电子,和带负电荷的负质子,以及不带电的中微子。

换句话说,我们人类存在的于绝大多数都是“质子+电子”元素组成的世界。同样也会存在一个由“负质子+正电子”元素组成的地方,那里的东西表面上看起来和我们这儿的没什么区别。比如刚好有一个“反你”,你激动地和它来个拥抱,噗~正负抵消,你们两就这么消失了。

生命的本质——逆熵

宇宙在膨胀,它将走向何方?科学家给出了一种假说——热寂!

日月星辰,时光流逝,在浩瀚的时空中,万事万物都按照自身规律在运作着。可不论渺小的沙粒,或是伟大的太阳,它们都有终结的那天。这里所说的终结,便是自身无法维持规律而走向无序。

熵,表示无序的程度,宇宙目前处于熵增当中(越来越无序),让所有天崩地裂过后,万物无序,熵趋于无穷大,这便是热寂。

可是地球上却偏偏出现了有机生命,它的运作方式非常另类。

生命的源起是DNA,它会主动吸收周围的介质,并将其同化为自身的化学结构。我们把这种现象称为“自我复制”。至少目前为止,我们还未发现一粒沙子、一滴水、一种金属存在这种自我复制的现象。

如果地球或者太阳最终走向无序而告终,你就会发现,生命的自我复制行为,其实就是源源不断把周围无序的元素重新排序。

我们不断繁衍,生生不息,生命系统越发复杂,在热寂这一大命运前,生命似乎在呐喊,就是要逆熵而行!

简单书评

首先要声明,本书是我断断续续读完的。所以在内容衔接上可能有所“断片”。但总体而言,不论是作者引入了很多哲学思辨或是历史典故,我觉得并没有起到点睛之笔的作用,反而稍显累赘。此外,文中难免充斥着不小的篇幅关于物理公式和专业的逻辑推理,作为科普读物来说(或者就我个人而言)读起来稍显吃力。

本书作为《七堂极简物理课》的进阶版,更多的是体现在专业性的角度。毋庸置疑,书中确实在很多物理层面上更加深入地展开讨论,但恰恰是这些有深度的内容,让像我这样的小白只能感叹——望成莫及。

总而言之,我对本书的评价不算太高。然而对于豆瓣评分9+以上,我只能说,主要因为我智力水平有限。它要求读者有一定的物理基础,否则仅凭想象,很难对量子物理有一个感性的认知。

不论如何,如果让我读起来发懵的书本,我认为还是整理成读书笔记比较好。

有限感官

我们对现实的理解取决于我们具备什么样的感官,包括视觉、嗅觉、味觉、听觉、触觉等。但你必须知道,这些感觉是有范围限制的,比如我们能看到,又不能完全看到,因为在可见光之外,还有更多频率的电磁波(光既是电磁波);再比如声音,我们能听到,又不能完全听到,因为人耳能感知的声音频率就那么一小段。同理,酸甜苦辣咸之外,可能还有人类无法品尝的美味。

好在人类善于利用工具,可以通过不同的设备仪器探测到那些我们无法感知的事物。但…如果这些工具的感知能力也到了极限呢?

是的,本书围绕二十世纪物理界最重要的两个理论展开讨论:

  • 广义相对论重新定义了引力、时间和空间,它的研究对象很大,超过了人类已知的世界,超出了人类可想象的维度。
  • 量子理论彻底颠覆了经典物理学,它的研究对象很小,小到比已知的最小粒子还要小上亿倍,小到同时存在又不存在,小到人类根本不可能观测到。

虽然它们已经超出了人类有限的感官,但从理论出发,在实践中证明,这两个理论对现代科学,包括天体物理、核能、航空航天、计算机、通信等大部分领域都有极高的应用价值。然而讽刺的是这两个理论却是自相矛盾的。

很可能,二者的矛盾依旧源自人类的有限感官——其实不过是同一件事物的不同面罢了。科学家希望能有一种大统一的理论来让二者和睦相处,目前比较主流的是弦理论圈量子理论

本书的作者显然是圈量子理论的拥护者,换句话说,本文以及本书都是在描述圈量子理论,目前它仅仅是一种观点。(别太较真!)

时间和空间是同一种东西

传统意义上,我们会把时间、空间看作是两个独立且客观的事物。我们会觉得不论物质如何运动或者相互作用,因为它们“被包含在”时间和空间当中。空间是绝对独立的,时间亦是如此。但根据爱因斯坦的广义相对论:时间和空间是同一种东西,统称为“时空”。

时空也并非独立客观的,尽管物体存在于时空中,但时空会因物体的质量被扭曲——这便是万有引力的本质。

一个有趣的假想:越靠近大质量天体的地方,时空密度越高,时间就会越慢。如果一对双胞胎,哥哥住在山脚,弟弟住在山顶,多年之后,哥哥会比弟弟还年轻。

当然,这也只能是假想,因为地球的质量没那么大,如果能在黑洞上做这个实验,那就真的是“一日不见如隔三秋”啦。

but,圈量子理论认为:这个世界根本不存在所谓的时间。当然也没有所谓的真空,因为“空间”本身就是一种存在,它才是万物的根本,构成空间的是一种叫“空间量子”的东东。

空间量子相互作用所产生的事件,被称为“物质”。什么意思呢?就像海浪,浪再大再猛烈再真实,它也只是无数水分子集体行为的结果,世上本没有浪,一部分水“跳”起来,浪就有了。所以,我们这个世界的万事万物可能是空间量子相互作用的结果。同理,物质的行为也会反过来影响空间量子——大质量的天体可以扭曲空间。

事件与概率

在量子世界中,是不存在“平滑连续”概念的,总有个最小粒度,一份一份地传递。在空间量子中亦是如此。

我1秒钟走了1米,这其中可能存在某0.00000001秒走了0.00000001米,当然这个值可以继续往下分,但有一个最小值——1.6x10-³³厘米。这是普朗克长度,它很短,但不为0。假如人类能做一把量子尺,上面的刻度精度也只能到这个极限,因为这是空间的最小单位,否则它属于时空。我无法想象,一种事物没有空间、没有时间、没有质量、没有能量、没有信息,什么都没有,凭什么它还存在?

好了,明确万事万物是不可无限分割的,才便于我们把物理公式中的时间变量t给拽走。

传统观念里,我们会认为“粒子”是构成物质的基础,不论它是电子、原子、夸克或者其他。但正如前文所说,圈量子认为,这些粒子其实是空间量子的集体行为事件(或者说作用结果),就像海面上的浪一样。

按照量子理论继续推导就会得出:粒子(物质)并非永恒的,它以什么形式存在或不存在只是个“概率”问题。而量子的行为是非连续的,是一个接着一个,称为“事件”,所以物质的运动其实是无数个短暂的瞬间。

这一切就像是看动画片,你以为它是连续的,其实它是一帧一帧的。这一切都和时间无关,动画播放的速度只取决于每帧之间的间隔,不论间隔多久,从一帧刷新到下一帧总是一瞬间的事。我们所存在的空间也是这样,空间量子总是一个事件接着一个事件在触发,这一切都不需要时间的参与。换句话说,宇宙中并不存在一个绝对统一的时间维度。

好了,本书我能吸纳的内容也就这么多,关于其他如:圈量子的形态、量子黑洞、万物皆比特等,实在是云里雾里。待我智商充值后,再来好好消化吧。

Only the Code Tells the Truth

程序的最终语义是给出可执行代码。如果它只有一段二进制码,就会非常难读懂!但如果你的程序是典型的商业软件开发、开源项目、或者动态解释语言的代码,它就应该可读。当你在阅读源码时,程序的意图就应该浮现出来。为了搞懂程序要做什么,最终你肯定要阅读源码。大多数精确的规范文档甚至都无法告诉你全部的真相:毕竟它无法容纳程序实际处理的全部细节,仅仅是高层次的需求分析。一份设计文档可能抓得住一部分设计规划,但它缺乏实现的必要细节。这些文档可能与当前的实现内容失去同步…或者就干脆放弃掉了,从一开始就没写过。而源码可能是唯一剩下的东西。

基于这一点,问一下自己要如何清爽地告诉你或者其他程序猿,你的代码要做什么。

你可能会说:“噢,我的注释就可以告诉你想要知道的任何内容。”但请记住,注释不是运行代码。它们会存在于文档一样形式的错误。流传着一种说法,注释理所当然是件好事,一些程序猿就不假思索地写越来越多的注释,甚至重复解释那些琐碎的已经很明确的代码。这对于阐明你的代码,完全是错误的。

如果你的代码需要注释,考虑重构它以免除注释。大量的注释会扰乱屏幕空间,甚至可能被IDE自动隐藏掉。如果你想要解释某个变更,那就在版本控制系统签入消息,而不是代码里。

你要做些什么才能让代码尽可能清晰地表达“真相”?争取一个好名字。结构化代码以内聚功能,选择简单的命名。解耦代码以实现正交性。编写自动测试来解释和检查接口的行为意图。当你学到更简约的代码或更好的解决方案时,果断重构。让你的代码尽可能地易于理解和阅读。

对待你的代码要像对待其他创作一样,如一首诗、一篇散文、一个博客、或是重要的email。仔细制作你要表达的内容,使其做它该做的,并尽可能直接传达出它正在做的事情;即便你离开后,它也能传达出你的意图。请记住,有用的代码会比预期使用更长时间。今后的维护者会感激你,如果你是维护者,并且你所处理的代码并不能轻易说明事实,请积极地应用上述准则。在代码中建立理智,并让自己保持理智。

One Binary

我遇到过几个项目,其中的构建代码会重写一部分,以便为不同的目标环境生成对应的二进制文件。这总是会让事情变得比它原来更复杂,并带来一个风险——对于每个安装包,团队可能都没有个统一的版本号。至少,这些多个编译目标几乎都是该软件的副本,只不过被部署到了不同的地方。这就意味着很多不必要的部分也被移植过去,也意味着错误的可能性更高。

我曾在一个团队中工作过,每个特性变更时都不得不进行一次完整的构建周期检查,因此每次微调时测试员都得随时待命(我有提到过构建时间也很长吗?)。我还在另一个团队中工作过,那里的系统管理员坚持要求在生产环境重新构建(使用我们的脚本),这就意味着我们无法证明生产版本是经过测试的。等等吧。

规则很简单:只构建一个二进制文件,你能够在发布管道内进行所有阶段的识别和升级。保持环境的具体细节在环境中。比如,把它们放进一个组件容器、或已知的文件、或路径。

如果你的团队将代码分离编译或是将所有目标平台设置存到代码里,那就说明这是一个没有经过慎重考虑的设计,不足以将应用程序的核心部分与平台细节分离开。另一种可能:团队知道怎么做,但不能先试着去改变。

当然,也有例外:你可能在为有着显著资源限制区别的目标平台做构建,但这并不适用于我们大多数编写“数据库筛查和返回”应用程序的人。又或者,你可能正遇到一些难以立刻修复的遗留问题。这种情况下,不得不逐步前行——但最好尽快开始。

还有一件事:也要确保环境信息的版本化。没什么比破坏环境配置和搞不清哪里被修改更糟糕的了。环境信息应当独立与代码进行版本化,因为它们会以不同的频率和原因发生变化。一些团队会采用分布式版本控制系统来进行管理(比如bazaar和git),因为它们很容易在生产环境中作出变更——必然的——也能回退到仓库。

News of the Weird: Testers Are Your Friends

不论你把他们称为质量保证还是质量控制,我们程序员通常叫他们挑事者。在我的映像中,程序员都会和测试他们软件的人树立敌对关系。经常可以听到一些抱怨:“他们也太挑刺儿了”以及“就是一帮处女座”。很熟悉的声音对吧?

我不确定为什么,我对测试员有着不同的映像。或许我的第一份工作中的“测试员”是公司秘书的缘由吧。Margaret是个非常nice的女性,总能看到她在办公室忙碌着,并且会试图教一些年轻的程序员如何在顾客面前表现得更专业。她有一种能在短时间内发现bug的能力,不论多么复杂。

说回来,我当时在处理一个由自认为是程序员的会计所写的程序。不消说,有很多棘手的问题。当我认为理顺了一部分问题后,Margaret就会试着用一下,不用多久,就能通过新的操作方式让它挂掉。有时真的很失落并有点尴尬,但她就是那种令人愉悦到我不会因为难堪而责怪她的人。最终,有一天Margaret能利落地打开程序,开出票据并打印,最后关闭。我很欣慰,更棒的是,我们在客户的电脑上安装好软件,一切正常。他们不会看到任何问题,因为在此之前Margaret已经帮我发现并修复了它们。

这就是我为什么说测试者是你的朋友。你可能会认为他们反馈一些微不足道的问题致使你难堪。但消费者却因不会遇到那些恼人的“小问题”而欣慰,QC助你修复了它们,而你也看起来很伟大。明白我的意思吗?

想象一下:你正在使用一款基于“突破性的人工智能算法”的测试工具来发现并修复并发问题。你启动它并立刻注意到屏幕上的“人工只能”拼写错误。有点扫兴,但只是个小错误,对吧?然后你又注意到配置页面中本该用单选的控件却用成了勾选栏,还有一些快捷键无法使用。现在,每个问题都没什么,但它们叠加到一起后,你就会开始问候写这个软件的程序员了。如果连这么简单的事情都做不好,你还指望他们的AI能帮你发现并修复这些棘手的并发问题?

他们可能都是天才,聚焦于AI领域并狂热地把它变得更伟大,以至于连这些小问题都从来不关注,没有“挑剔的测试者”来指出这些问题,而让你发现了它们。然后你开始质疑这帮家伙的水平。

所以,听起来可能很奇怪,那些励志要揭露你代码中的每个小bug的测试者,确实是你的朋友。

Missing Opportunities for Polymorphism

Polymorphism(多态)是面向对象中重要的思想之一。源于希腊语,即多(poly)面(morph)的意思。在程序的上下文中,多态是特定类的对象或方法的多种形态。但多态并不是简单的交替式实现。使用时要小心,多态会创建个很小的局部执行上下文,以让我们无需去关心那冗长的if-then-else代码块。在上下文内部允许我们直接处理事务,但在上下文外部却要强制我们重建它之后才能处理事务。谨慎使用交替实现,我们可以捕获上下文以让代码更少更可读。最好先看个代码示例,比如下面这段简单(到爆)的购物车:

1
2
3
4
5
6
public class ShoppingCart {
private ArrayList<Item> cart = new ArrayList<Item>();
public void add(Item item) { cart.add(item); }
public Item takeNext() { return cart.remove(0); }
public boolean isEmpty() { return cart.isEmpty(); }
}

假设我们的网店里陈列着可下载的(虚拟)产品和需要运输的(实物)产品。我们就要让对象支持这些操作:

1
2
3
4
public class Shipping {
public boolean ship(Item item, SurfaceAddress address) { ... }
public boolean ship(Item item, EMailAddress address) { ... }
}

当用户下单后,我们需要发货:

1
2
3
while (!cart.isEmpty()) {
shipping.ship(cart.takeNext(), ???);
}

代码中的的???可不是什么花式操作;而是用来询问我该用电子邮件还是快递发送这些商品。但回答这个问题的上下文并不存在,我们只能用布尔枚举的形式事先捕获发货方式,然后用if-then-else去补全这个参数。

另一种解决方案是创建两个继承于Item的类,称为DownloadableItemSurfaceItem。现在来实现代码,我会让Item作为一个接口,并支持单一方法——ship。为了能顺利发货购物车的商品,我们需要调用item.ship(shipper)。而DownloadableItemSurfaceItem两个类都需要实现ship

1
2
3
4
5
6
7
8
9
10
11
public class DownloadableItem implements Item {
public boolean ship(Shipping shipper, Customer customer) {
shipper.ship(this, customer.getEmailAddress());
}
}

public class SurfaceItem implements Item {
public boolean ship(Shipping shipper, Customer customer) {
shipper.ship(this, customer.getSurfaceAddress());
}
}

在这个例子中,我们将负责运输的工作委托给了每个商品。由于每个商品都清楚自己的最佳发货方式,这种模式可以继续下去,且不在需要if-then-else。这个段代码也示范了两种经常一起使用的设计模式:命令模式和双重调度。能否有效地运用这些设计模式取决于你是否谨慎地使用多态。这样一来,能够大幅缩减代码中的if-then-else。

尽管在某些情况下用if-then-else会比多态更实用,但更多时候,多态的编码风格将产出更小巧、更可读、也更健壮的代码库。错失机会的次数可能就是我们代码中if-then-else的数量。

本文源码:https://github.com/Philon/rpi-drivers/tree/master/07-pdd

从《树莓派驱动开发实战》的第一篇至今,都是在写单个字符设备,这其中不难发现个问题——如果我有10个LED灯就意味着我要写10个led字符设备驱动,而其中的大部分代码都是重复的,它们之间可能仅仅是控制引脚不同。

一是为了解决这个问题,二是之后的驱动开发更多会涉及USB、I²C、UART之类的总线设备,三是为了更好地理解Linux驱动架构。从本篇文章开始正式以驱动-总线-设备模型和设备树机制来编写设备驱动。我觉得以平台驱动设备模型作为切入点较好——可以不涉及真实的硬件。

驱动-总线-设备模型

Linux2.6之后引入了全新驱动注册管理机制:驱动、总线、设备。一句话,为了高内聚低耦合!

  • 驱动部分:负责实现设备的控制逻辑及用户接口,并注册到内核
  • 设备部分:负责描述设备的硬件资源,并告知内核
  • 总线部分:负责实现设备与驱动之间的感知、识别、匹配规则

举例来说,如果有100个按键(比如键盘),我只需要实现1个按键驱动+100个按键设备描述,并把它们挂到按键总线上,总线会负责把二者匹配起来,所有的按键就都可以用了。

内核提供了相应的bus、device、driver、class等最为底层的API和数据结构,即驱动、总线、设备模型来管理系统设备。但在日常驱动开发中,一般是用不到的。因为常见物理总线都基于这些API封装了对应的如usb_bus、usb_device、usb_driver、tty_device、tty_driver等接口,日常的设备驱动开发更多以这一层打交道。

用面向对象的思想来说,驱动总线设备模型就是DeviceManage基类,由此派生出了USB、TTY、I²C、SPI、PCIe、GPIO等设备管理机制。当然,这其中也包括platform平台设备管理。

1
2
3
4
philon@rpi:~ $ ls /sys/bus/
amba container genpd i2c media mmc_rpmb scsi usb
clockevents cpu gpio iscsi_flashnode mipi-dsi nvmem sdio workqueue
clocksource event_source hid mdio_bus mmc platform spi

通过/sys/bus目录可以看到系统当前存在的总线。👆注意看倒数第二个,platform平台总线出现了!!

平台总线、平台驱动、平台设备

platform是一种虚拟总线。它是“驱动-总线-设备”模型的一种实现,与usb_bus tty_bus spi_bus等物理总线平级。为了把那些不走总线架构的设备囊括进来。

回顾历史:

  1. 编写字符设备cdev时需要关心主、次设备号,还要用mknod命令创建对应的设备节点。
  2. 于是内核提供misc混杂设备,将所有不好管理的字符设备统一主设备号为10,自动分配和创建节点,本质上就是基于cdev再封装了一层。
  3. 为了管理总线设备,内核又提出了驱动总线设备模型,并封装了各种USB、I²C、TTY等软件层。
  4. 为了把非总线架构的设备也用总线思想来管理,内核提出了平台驱动设备

所以,platformmisc一样,都是为了给“其他”设备找一个爸爸。

最简单的PDD实例

根据上述可推断,platform_bus(即总线部分)内核已经实现了,所以我们只需要实现两边的platform_xxx_driverplatform_xxx_device即可,然后把它们挂到平台总线上去,总线会自动进行匹配的。以下只是以led为例说明platform相关接口用法,并未真正实现led驱动。

led_driver.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <linux/module.h>
#include <linux/platform_device.h>

MODULE_LICENSE("Dual BSD/GPL");
MODULE_AUTHOR("Philon | https://ixx.life");

// 当总线匹配到设备时调用该函数
static int led_probe(struct platform_device* dev) {
printk("led %s probe\n", dev->name);
// todo: 字符设备注册、gpio申请之类的
return 0;
}

// 当总线匹配到设备卸载时调用该函数
static int led_remove(struct platform_device* dev) {
printk("led %s removed\n", dev->name);
// todo: 其他资源释放
return 0;
}

// 平台驱动描述
static struct platform_driver led_driver = {
.probe = led_probe,
.remove = led_remove,
.driver = {
.name = "my_led", // 👈务必注意,platform是以name比对来匹配的
.owner = THIS_MODULE,
},
};

// 【宏】将led驱动挂到平台总线上
// 相当于同时定义了模块的入口和出口函数
// module_init(platform_driver_register)
// module_exit(platform_driver_unregister)
module_platform_driver(led_driver);

led_device.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <linux/module.h>
#include <linux/platform_device.h>

MODULE_LICENSE("Dual BSD/GPL");
MODULE_AUTHOR("Philon | https://ixx.life");

// ⚠️最好实现该接口,否则在设备释放的时候内核会报错
static void led_release(struct device* pdev) {
printk("led release!\n");
}

// 平台设备描述
static struct platform_device led_device = {
.name = "my_led", // 👈要确保与led_driver的定义一致,否则匹配不上
.dev = {
.release = led_release,
},
};

// 将设备注册到平台总线
static int leddev_init(void) {
platform_device_register(&led_device);
return 0;
}
module_init(leddev_init);

static void leddev_exit(void) {
platform_device_unregister(&led_device);
}
module_exit(leddev_exit);

简述一下代码的逻辑:

  1. platform_bus监听到有device注册时,会查看它的device.name
  2. platform_bus会查找所有的driver.name,找到之后将设备和驱动进行绑定
  3. 绑定成功后,platform_driver.probe()将触发,刚才的设备作为参数传递进去
  4. 剩下的事情,就看你如何实现platform_driver了…

实际操作下,加载led_driver.ko模块后,可以在平台总线目录下看到my_led驱动了。然后,加载led_device.ko模块后,同样可以在平台总线设备里查看到my_led.0的设备。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 平台总线里查看my_led驱动
philon@rpi:~/modules $ sudo insmod led_driver.ko
philon@rpi:~/modules $ ls /sys/bus/platform/drivers/my_led/
bind module uevent unbind

# 平台总线里查看my_led设备
philon@rpi:~/modules $ sudo insmod led_device.ko
philon@rpi:~/modules $ ls /sys/bus/platform/devices/my_led.0/
driver driver_override modalias power subsystem uevent

# 看一下内核打印信息
philon@rpi:~/modules $ dmesg
[225668.547712] led my_led probe
[225687.213336] led my_led removed
[225687.213448] led release!

⚠️温馨提示:不必操心driver/device模块的加载顺序,谁先谁后都一样,platform_bus会料理好一切。

以上,便是PDD模型的一个基本展示,如果你愿意,可以在led_device.c文件里多注册几个设备。不过在此之前——你内心难道不会充满疑惑吗:这tm怎么匹配上的呀?🤔️

平台驱动和设备的匹配

看一下内核是如何实现平台匹配的,非常容易理解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// linux-rpi-4.19.y/drivers/base/platform.c line:963
static int platform_match(struct device *dev, struct device_driver *drv)
{
struct platform_device *pdev = to_platform_device(dev);
struct platform_driver *pdrv = to_platform_driver(drv);

/* When driver_override is set, only bind to the matching driver */
if (pdev->driver_override)
return !strcmp(pdev->driver_override, drv->name);

/* 首先尝试设备树匹配(OF - Open Firmware Standard) */
if (of_driver_match_device(dev, drv))
return 1;

/* 然后尝试匹配高级配置和电源接口
(ACPI - https://baike.baidu.com/item/ACPI/299421?fr=aladdin)
*/
if (acpi_driver_match_device(dev, drv))
return 1;

/* 然后尝试匹配ID表 */
if (pdrv->id_table)
return platform_match_id(pdrv->id_table, pdev) != NULL;

/* 最后尝试匹配驱动和设备的名称 */
return (strcmp(pdev->name, drv->name) == 0);
}

从以上代码可以看出,平台总线的匹配经过设备树 > ACPI > ID表 > 名称等4种方式匹配,只要任意一种属性确认过眼神,就可以进行下一步。所以led_driverled_device能够匹配上,正是因为它们内部的name值相同。

接着,看看平台驱动和平台设备的数据结构,一切就明朗了。

在平台驱动的数据结构中可以看到,它内部包含了底层的device_driver结构,如果驱动想要只是某些类型的设备,那就必须在相应的用于匹配的属性里事先声明。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
struct platform_driver {
int (*probe)(struct platform_device *);
int (*remove)(struct platform_device *);
void (*shutdown)(struct platform_device *);
int (*suspend)(struct platform_device *, pm_message_t state);
int (*resume)(struct platform_device *);
struct device_driver driver; // 底层驱动数据结构
const struct platform_device_id *id_table; // ID的匹配表👈
bool prevent_deferred_probe;
};

struct device_driver {
const char *name; // 驱动名,用于名称匹配👈
struct bus_type *bus; // 总线类型,如platform_bus
struct module *owner; // 这就不解释了
const char *mod_name; // 用于构建模块
bool suppress_bind_attrs; /* disables bind/unbind via sysfs */
enum probe_type probe_type;

const struct of_device_id *of_match_table; // 设备树的匹配表👈
const struct acpi_device_id *acpi_match_table; // acpi的匹配表👈

int (*probe) (struct device *dev); // 探测设备:当匹配成功时回调
int (*remove) (struct device *dev); // 移除设备:当设备卸载时回调
void (*shutdown) (struct device *dev); // 关闭设备
int (*suspend) (struct device *dev, pm_message_t state); // 暂停
int (*resume) (struct device *dev); // 恢复

const struct attribute_group **groups;
const struct dev_pm_ops *pm; // 电源管理
void (*coredump) (struct device *dev); // 核心转储
struct driver_private *p; // 私有数据
};

和平台驱动很类似,其内部同样包含了底层的device结构体,如果设备想要被总线匹配上,同样要在自己的属性里配置好。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct platform_device {
const char *name; // 设备名,与驱动的name匹配 👈
int id; // 设备ID
bool id_auto;
struct device dev; // 底层数据结构
u32 num_resources; // 设备要用的资源数量
struct resource *resource; // 设备的硬件资源描述

const struct platform_device_id *id_entry; // 设备ID,与驱动的ID表匹配 👈
char *driver_override; /* Driver name to force a match */

/* MFD cell pointer */
struct mfd_cell *mfd_cell;

/* arch specific additions */
struct pdev_archdata archdata;
};

好了,本文并不打算详细讨论有关平台、驱动、设备及其如何匹配的原理。如果对此感兴趣或想要深入研究,请用好互联网。我个人也查了很多资料,感觉这位作者写的《Linux Platform驱动模型(一) _设备信息》《Linux Platform驱动模型(二) _驱动方法》还阔以,适合入门。

那么接下来,我们已经知道平台总线提供了4种匹配方法,name匹配就不说了,另外两种不提也罢,最最最重要的设备树匹配该登场了。

设备树

先声明,关于设备树的语法、树莓派的配置规则,不会涉及太多。本文侧重于实战,原理知识请用好互联网。

从历史上说,ARM-Linux引入设备树完全是被逼出来的。我们知道ARM以IP授权的商业模式运作,诞生了众多芯片厂商。它不像x86/x64架构只有Intel之类的寡头,产品大同小异比较好管理。arm的江湖可谓鱼龙混杂,每家都想在Linux内核种争的一席之地。可偏偏这帮家伙把又是硬件出生,对于兼容自家的不同产品只会用if-else,作为软件大神的Linus自然是怒了:“策略模式”难道不香么,你以为用C写一堆“电路板说明书”很高级么?如果你愿意,可以浏览下内核目录arch/arm/mach-xxx,非常多对吧。其实这些目录大多是SoC的硬件细节描述,用于适配各大厂商不同型号的处理器或开发板。

闲话就扯那么多,总之,设备树就是用类C的文本语言编写,用于描述Soc及其外围电路模块的配置文件。通常情况下它由bootloader传递给内核。这种做法,极大的降低了驱动的维护难度,也大大增加了系统设备管理的灵活性。

⚠️在阅读下文前,必须基本懂得两个知识点:

  1. 设备树语法,我就不多嘴了,网上一搜一大堆
  2. 树莓派overlay机制,这个网上几乎没有,我做个大概说明

树莓派的设备树配置

本小结是从👉树莓派DeviceTree的官方介绍中总结而来,如果想更全面地了解,可以看原文。

一个常规的Arm-Linux设备树,主要是由源文件.dts和头文件.dtsi共同编译出.dtb二进制,内核在初始化后会加载这个dtb,并把相关设备都注册好,就可以愉快地使用了。例如树莓派3B+,/boot/bcm2710-rpi-3-b-plus.dtb就是树莓派SoC和外围电路的默认配置。

对于大部分硬件产品来说这没什么问题,例如一部手机在出厂以后,它的硬件几乎是不会变的。但对于树莓派这种开发板来说,尤其是它的40pin扩展引脚,外围电路的变动可就大了去了,而内核加载dtb后是不能变的,所以需要一种动态覆盖配置的设备树机制,这就是树莓派的——dtoverlay(设备树覆盖)。

dtoverlay同样是由dts源编译而来,语法几乎和设备树一样,不过输出文件扩展名为dtbo。树莓派提供了两种方式加载dtbo:

  1. 将编译好的dtbo放到/boot/overlays下,并由/boot/config.txt配置和使能;
  2. 通过命令dtoverlay <dtbo_file>动态覆盖设备树;

第1种方式会涉及更复杂的语法规则,本篇文章仅仅是对平台设备及设备树的知识入门,因此选择第2种命令行的方式,动态加载。

用设备树注册设备

led_driver.c:
其他内容不变,仅仅是增加of_device_id属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 首先用of_device_id声明了三种LED型号的表,支持设备树解析
static const struct of_device_id of_leds_id[] = {
{ .compatible = "led_type_a" },
{ .compatible = "led_type_b" },
{ .compatible = "led_type_c" },
};

static struct platform_driver led_driver = {
.probe = led_probe,
.remove = led_remove,
.driver = {
.name = "my_led",
.of_match_table = of_leds_id, // 👈在驱动种添加对应属性
.owner = THIS_MODULE,
},
};

接着新建一个设备树文件,并定义一个led_type_a的LED设备,并将其命名为led_a1

myled.dts

1
2
3
4
5
6
7
8
9
10
11
12
13
/dts-v1/;
/plugin/;

/ {
fragment@0 {
target-path = "/";
__overlay__ {
led_a1 {
compatible = "led_type_a";
};
};
};
};

fragment__overlay__非常重要!!如果不这么写会导致动态加载失败,但其实以上的代码转化为标准的设备树语法为:

1
2
3
/led_a1 {
compatible = "led_type_a";
};

最后用dtc编译器将dts编译为dtbo

1
linux-rpi-4.19.y/scripts/dtc -I dts -o myled.dtbo myled.dts

万事俱备,看看效果吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 第一步:加载led驱动
philon@rpi:~/modules $ sudo insmod led_driver.ko
# 第二步:加载设备树覆盖
philon@rpi:~/modules $ sudo dtoverlay myled.dtbo
# 第三部:看看平台设备里是否注册了一个叫“led_a1”的设备
philon@rpi:~/modules $ ls /sys/devices/platform/
alarmtimer Fixed MDIO bus.0 👉led_a1👈 power serial8250 uevent
...

# 显然已经注册,根据led_driver的实现,设备注册后会在probe函数中打印一条消息
philon@rpi:~/modules $ dmesg
...
[ 429.359567] leddrv: no symbol version for module_layout
[ 429.359577] leddrv: loading out-of-tree module taints kernel.
[ 435.995744] led led_a1 probe 👈啊~我看到树上长了个灯

再来回顾下流程:

  1. 首先驱动要支持of_device_id属性,并且以compatible作为匹配对象
  2. 然后通过编写设备树定义相应的设备资源
  3. 最后通过加载驱动和dtoverlay即可

让设备开机自动注册

这就非常简单了,前面已经说过/boot/overlays其实是通过config.txt配置和使能的,所以我们只需要将myled.dtbo放到overlays目录下,并在config.txt添加一行使能即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 第一步:将自己的dtbo放到overlays下
philon@rpi:~/modules $ sudo cp myled.dtbo /boot/overlays
# 第二步:在config.txt最后一行添加myled
philon@rpi:~/modules $ sudo echo "dtoverlay=myled" | sudo tee -a /boot/config.txt
# 第三步:reboot...
# 第四步:可以在/sys/device/platform下查看到设备已经注册
philon@rpi:~/modules $ ls /sys/devices/platform/
alarmtimer Fixed MDIO bus.0 👉led_a1👈 power serial8250 uevent
...

# ⚠️但是,设备树仅仅是定义了led_device,而led_driver.ko其实并没有开机加载,如果要更完善的话,应该把led_driver直接编译进内核!
philon@rpi:~/modules $ sudo insmod leddrv.ko
philon@rpi:~/modules $ dmesg
...
[ 214.076752] leddrv: no symbol version for module_layout
[ 214.076771] leddrv: loading out-of-tree module taints kernel.
[ 214.077535] led led_a1 probe

小结

  • Linux-2.6后引入了驱动-总线-设备的软件架构来管理系统设备;
  • platform设备和USB、TTY、UART一样,都是基于底层的抽象和封装;
  • platform是为了把那些没有总线的设备,以总线的思想管理起来,所以它算作一根虚拟总线;
  • 平台总线提供了多种驱动和设备的匹配规则:设备树、ACPI、ID表、名称等;
  • 设备树是由bootloader传递给内核,并且在初始化后基本不可修改;
  • 树莓派为了满足设备树动态修改的需求,引入了dtoverlay
  • dtoverlay采用常规的设备树语法,但需要fragment__overlay__属性;
  • 驱动必须定义of_device_id数据结构,才能与设备树匹配;
  • 务必掌握设备树语法!

本书全名为《上帝掷骰子吗:量子物理史话》,所以是讲量子物理及其发展历史的科普书,由于“量子”对我这种普门外汉而言较为高深,所以我的理解可能与原著会有极大的偏差。

光,是什么?

光是一种物质吗?光有具体的形状吗?为什么宇宙的极限速度是光速?当然,古代的人们会告诉你:“上帝说要有光,于是就有了光”。不过对于这帮物理学家而言,这是毫无说服力的。

很早以前,物理学家们就从科学的角度去解释光到底是什么,然而谁也说不清。不过业界争论的焦点在于:光是一种粒子,还是一种波。并由此引发了三次波粒战争。

第一次波粒战争:彩虹

17世纪,以鼎鼎大名的艾萨克·牛顿为首,支持“微粒说”;以同样著名的物理学家胡克为首,支持“波动说”。

其实当时牛顿最初还相对中立,因为没有确凿的实验证据。但胡克这家伙总是对他冷嘲热讽,牛顿的个性也相当尖锐,因此干脆一边倒支持微粒。

最终,牛顿通过三棱镜发现了阳光其实由7中不同色彩的光组成,光被假想为不同颜色的小球混合在一起的结果。当时牛顿已发现了万有引力而闻名天下,再加上这么个极具说服力的实验结果,舆论就支持微粒,“波动说”输的体无完肤。

第二次波粒战争:双缝衍射、电磁波

18世纪,一名叫托马斯·杨做出了光的双缝衍射实验,发现光穿过两条小缝后,墙面会出现明暗相间的条纹——这可是波的专属。由此波动说异军突起。

之后,菲涅尔更是通过数学推理出光照到一小块圆片上,会因为衍射而在其影子的中心有一小块亮斑,后期被实验证明,这便是著名的柏松亮斑。

紧接着,伟大的赫兹登场了,他做了一个非常简单却非常牛逼的试验:

通过给两个金属铁球充电直到激发击穿电压,此时可以在空气中看到电火花;同时,在附近的另外两个金属铁球之间,也会附和着产生电火花,而它们却没接入任何电源。

通俗的解释试验原理:好比在平静的水平上晃动一个球,水面产生的波纹会导致另一个球跟着晃动。是的,这个试验证明电磁波的存在,无线通信由此产生,没有它,就没有我们的Wi-Fi蓝牙。

又是光的衍射、又是电磁波,波动说终于扳回一局!

第三次波粒战争:黑体辐射、量子、光电效应

19世纪,人们开始研究黑体辐射,所谓黑体是一种理想物质,可以对其无限升温,随着温度的升高,该物体会向发光,颜色从红——橙——黄——白——蓝渐变。正如宇宙中常见的恒星一样。简而言之,物体温度越高,电磁波(即光)的波长越短。那有没有一套公式能推导出温度和波长的关系?

抱歉,科学家总结的公式不仅复杂,而且与实际观测结果总是对不上号。必须用“长波公式”和“短波公式”来描述不同颜色下能量与波长的对应。就好比必须用两个路码表,一个只在100km/h内有效,一个只在100km/h以外有效,见鬼了。

终于,伟大的普朗克来了,它假设:“能量的传递必须有一个不连续的单位,一份份的传递”,叫做“能量子”——也就是后来的量子。带着如此叛逆的想法,他重新整合了实验数据,并得到了以下方程:

E = hv

E代表能量,h是普朗克常数(神秘的量子常数),v是电磁波波长。

如此简洁,并能完美地应用到了任何波长当中。但背后却是一个如此叛逆的思想——能量有一个最小值!这种说法就像在宣称“圆周率3.1415926….一定有结束的那一位”!不过,这个概念在生活中很容里理解,比如一张照片,哪怕它有100亿像素的分辨率,它的最小单位也只能是像素,不可再往下分。

量子——能量是一份一份的传递的,也就是说能量是一种微粒!

好吧,普朗克一手开启了量子的新纪元,却因为思想过去颠覆而不肯面对,量子从出生就惨遭抛弃,谁也没想到再随后的一个多世纪里,它饱经沧桑,受尽人间冷暖,终于闯出了自己的一片天地。

如果说量子的概念还没让“微粒说”站稳脚跟,那么接下来的故事真可谓:成也萧何,败也萧何。

波动说凭借电磁波的发现扬眉吐气,打得微粒说满地找牙。但是,电磁波的试验留下了一个巨大的BUG——当光照在金属铁球表面时,电磁波更明显,后来证明这是由于光照在金属表面,会打出一部分电子——光电效应。

光电效应更重要的一点是:再强烈的红光一个电子也打不出,再微弱的蓝光也可以打出电子。说明打出电子只和光的波长相关,和光束本身的能量大小无关,为什么?

爱因斯坦登场了:根据量子理论,假设光由光子组成(光的量子),根据普朗克的E=hv,单个光子的能量只和它的频率大小(波长)有关,当光子的能量超过电子时,才能把它撞出来。再说强烈的红光,这仅能说明红光的光子数量庞大,但每个光子的个体能量依旧低于电子,量子理论中能量是一份份传递的,作用结果不累加,所以红光打不出电子。就像小孩搬石头,一次只允许一个人搬,而小孩的力气小于石头的重量,你就是把全世界的孩子叫过来搬——一块也搬不走。

量子、光子,微粒说还是打出了自己的一片天地,战争发展至此,双方都骑虎难下,只能论持久战了。不论战争的输赢,量子这个叛逆的孩子,早已在双方的激战中成长起来。

卷土重来,电子的波粒二象性

同样是19世纪,人们发现原子并非世间最小的物质,它由原子核及其周围的电子构成。伟大的物理学家玻尔,通过量子理论和实验观测,推导出了原子内部结构——电子围绕着原子核运动,且电子的运行轨道是量子化的。

但很快,人们发现这套理论的bug,电子围绕原子核运行时需要释放电磁辐射,从能量守恒的角度,电子会因能量衰减而坠落中心。于是乎有个叫德布罗意的科学家提出了一个假设:电子的运动伴随着一个波。这个概念解释起来很复杂,总之当电子以波的形式运动,能够支撑它稳定在自己的轨道中。

电子是个波?!

我们努力从量子概念来解释原子内部结构,解释能量的传递方式,结果用尽毕生的努力,从微粒的角度居然推导出一个波?!

电子双缝衍射

好吧,要证明/证伪电子是一种波很简单,只要是波就有衍射现象,那就让电子通过两条小缝,看看电子感光屏上会不会出现明暗相间的条纹。

结果——真的出现了!

接着有人提出,这种衍射是电子们的群体行为,很正常。你不信一次只发射一个电子,衍射图案肯定就没了。

结果——还是有衍射!

注意,实验过程是一次只发射一个电子,感光屏上会随机留下一个点,发射多足够多次后就会看到,这些点其实排列成了多条明暗相间的衍射条纹——就像事先约定好了一样。

到这里,连物理学家都懵逼了,电子是个波我认了,但一个电子怎么可能同时穿过双缝——自己衍射自己?事实胜于雄辩,让我们看个究竟…

结果——衍射行为消失了。

经过反复试验确认得出个结论:当人们不观察时,一个电子也能衍射;可当人们观察其过程时,电子就像颗普通的玻璃球,只是随机穿过其中一条缝。至于为什么,至今无解!

看不到,就用理论证明

电子“存心”不让人类看到它是如何运动的,那好,根据实验结果,至少在理论上描述一下吧?然而接下来发生的事情很讽刺…

海森堡登场,他站在粒子的角度,通过量子理论推导出了一套矩阵力学(其实就是数学中的线性代数),可以很好地计算出电子在不同轨道上的能量。

薛定谔登场,他站在波动的角度,通过经典数学,也得出了一套方程式,能够很好地计算出电子在轨道和能量之间的对应关系。

呵呵,看来,电子既是粒子也是波,世界是连续的也是非连续的。打了几个世纪的波粒战争,结果却是盲人摸象,一派摸到了这边,一派摸到了另一边!只见电子笑而不语——傻了吧?粒子和波是同一种东西。

是啊,人类所在的维度太低,根本无法想象,一只手既是左也是右,一个球可以同时穿过两扇门,一只猫既是死的也是活的,一种物质既是连续的也是不连续的。

一些假说

物理学家的争吵还在继续,如果我们继续追随他们的足迹,只会一脸茫然而不知所措。不必沮丧,“没人能理解量子论”,就连爱因斯坦对此也是彷徨的。

首先要明确,量子的几大(诡异)特性:

  1. 不连续,总有个最小的、不可分割的单位值
  2. 随机,例如电子出现的位置是无法计算的,只能用概率统计
  3. 叠加态,常识中截然不同的两种状态会同时存在
  4. 超距作用,不论相隔多远,两个量子的行为可以瞬间同步
  5. 不可观察,量子的行为表现,取决于你如何观察它
  6. 未来可以决定过去,通过量子纠缠

总之,量子的世界很魔幻,但唯一可以肯定的是,那里没有上帝!此外总结一些主流的假说,便于更形象地理解量子理论。

平行宇宙

我们,来自更高维度的投影。就像地上的影子,它是二维的,对于二维世界来说,每个影子都认为自己的独立客观存在的。但是站在三维世界的我们很清楚,人的影子数量是无限的,只要光源不同,影子就不同。

当没有光(不观察)的时候影子是游离状态,一旦有光(观察)的时候,影子会瞬间聚合成形。而影子的形状取决于光的角度,同一个人的不同影子可以高矮胖瘦,同时出现。

用影子做比喻可能不恰当,但大意就是这样,每当我们观察的时候,就会创造一个全新的宇宙。

隐变量

听说过虚数吗?我们从小就明白一个道理——负负得正!请你想象一个数字:

i² = -1

是的,存在这样一种数字,它自己乘自己后的结果是一个负数。而且,虚数这种思想在数学和物理界都非常有用。那虚数是什么?不好说,只能想象成一个我们看不到的“维度”,既然看不到,就叫它们隐变量。

比如,存在这样一根线,它恰好同时垂直于xyz轴——你能想象吗?如果存在,那这根线的每个坐标值都是虚数。

这类似于前面所说的平行宇宙,有一些物质,它的坐标除了三维之外,还有我们无法想象的更高纬度,必须要把那部分的坐标值代入,才能精确计算。

超弦理论

这部分我不是很懂,大意是说,万事万物都是由无数条一维的线构成,这根线在不停震动,这就是弦。如果进一步观察,会看到弦其实更像一根水管,中间是空的…不是说一维的吗?无法理解…

一点点哲学

尝试理解量子理论,包括本书,都不得不引入很多哲学概念,实在没办法,量子的概念过于飘渺,而且很多观点也真的只是想象而已!不论如何,我比较喜欢平行宇宙的假说,起码它更客观一点,更好理解一些。

比如,观察一下就会创造一个全新的宇宙。可能会觉得太过于夸张,但仔细想想,我们来自高纬度的投影,那我们所看到的就是客观真实的吗?

白马非“白”马,人的眼中马是白色的,那拥有温度感官的蛇的眼中呢?有超声波感官的蝙蝠眼中呢?有紫外线感官的蜜蜂眼中呢?白色,只是马对太阳光的反射罢了,恰巧人眼能感知7中颜色而已,里面还有太多太多的“颜色”是我们根本感受不到的,但不代表它不存在。

同样的,什么是客观上的“我”,朋友眼里有个“我”的投影,那就等同于朋友对我进行了观察,创造了一个全新的“我”。有无数人观察过“我”,世上就有千千万万个“我”,有人认为“我”善良,有人觉得“我”阴险,“我”同时存在不同地方,“我”总是随机出现,“我”已经死了,“我”还活着。这完全取决于那个观察“我”的人。

量子,最神秘的幽灵,它颠覆了物理学,也被应用到了无数的行业。毋庸置疑,人类目前正游离在真理的边缘,世界将被重新阐释。

A Message to the Future

多年来,我一边培育程序员,一边和他们工作。也许是他们大都很聪明的缘故,人们也大都认为他们整日苦思冥想的问题是极其困难的,给出来的解决方案对于普通人(或者初学者)而言像天书一样难以理解和维护。

我记得一件在Joe身上发生的趣事,他是我数据结构班里的学生,突然跑来给我看他写的东西:“我敢打赌你猜不出这是什么”,他说。

“确实”,我同意地说,并不想在他的作品上花太多时间,也不在乎传递的消息有多重要。“我敢肯定对此你很努力吧。我想,我觉得,你是不是忘了什么事。说!Joe,你是不是还有个弟弟?”

“是的呀,他叫Phil!在你的入门班里,也是学编程!”Joe很自豪地说。

“非常好,”我回答。“我想知道他是否能读这段代码。”

“不行!”Joe说,“这是很难的!”

我建议说,“假设这是真的可以工作的代码,几年后,Phil受雇去维护更新它。你能为他做点什么呢?”Joe盯着我眨眨眼。“我们都知道Phil很聪明,对吗?”

Joe点点头,嬉皮笑脸的说:“我不想这么说,但我也很聪明呀!“

”所以当我不能理解你所做的事,而你非常聪明的弟弟也对此十分困惑,那对于你写的东西又意味着什么呢?”在我看来,Joe看待他的代码有所不同。“这样如何,”我以“我是你最友好的导师”的口吻建议:“设想一下,你写的每行代码都是给未来某个人的消息——有可能是你的弟弟。你正在给这个聪明人解释如何解决这个问题。(…未来…)这是你的想法吗?未来那个聪明的程序员看到你的代码并赞叹:‘哇!太厉害了!我可以完美理解它如何工作的,它是多么优雅。不,等等——这段代码太漂亮了,我要展示给团队的其他人看。这就是一份杰作!’”

“Joe,你认为能把这些解决了棘手问题的代码,也变得像歌曲一样优美吗?是的,就像一首令人陶醉的旋律。我觉得任何人只要能为困难提供解决方案,那他也能写出漂亮的东西。嗯…我想我应该开始在’漂亮’这方面打分了?你认为如何呢,Joe?”

Joe拿起他的作品看着我,露出一丝微笑。“我懂了,教授,我要为Phil创造一个更美好的世界。谢谢你。”

本文源码:https://github.com/Philon/rpi-drivers/tree/master/06-infrared

由于我手上只有一个1838红外接收头和一个CAR-MP3遥控器,所以本文主要基于Linux内核实现红外NEC协议的解码。

先来看看效果:

红外通信原理

呐,太专业的电路原理呢我就不展开讲了,反正也没人看。简单点说吧:

反正就是有一对红外发射管和接收管组成,通过产生脉冲信号来传递信息。脉冲信号是什么?你可以理解为摩尔斯电码那种样子,就是1和0。

红外通信在日常生活中主要应用于家电控制,例如电视、空调、投影等等。市面上比较常见的红外通信协议是NEC,所以就来研究以下NEC的解码。

NEC协议

在讲述NEC协议之前,先来看看下面这几行数据打印。这是我随便按了几下遥控器,抓取的红外原始数据。“横杠”表示有红外信号,“下划线”表示无信号。

1
2
3
4
5
6
7
8
philon@rpi:~/modules $ dmesg
# 9ms 4.5ms 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 0 0 0 1 0 0 0 0 1 1 1 0 1 1 1 1 1
[ 203.718032] -----------------_________-_-_-_-_-_-_-_-_-___-___-___-___-___-___-___-___-_-_-_-___-_-_-_-_-___-___-___-_-___-___-___-___-_
[ 207.647870] -----------------_________-_-_-_-_-_-_-_-_-___-___-___-___-___-___-___-___-_-_-___-___-___-_-_-_-___-___-_-_-_-___-___-___-_
[ 209.927802] -----------------_________-_-_-_-_-_-_-_-_-___-___-___-___-___-___-___-___-_-___-_-___-___-_-___-_-___-_-___-_-_-___-_-___-_
[ 214.557679] -----------------_________-_-_-_-_-_-_-_-_-___-___-___-___-___-___-___-___-_-___-_-_-_-_-___-_-___-_-___-___-___-___-_-___-_
[ 216.917629] -----------------_________-_-_-_-_-_-_-_-_-___-___-___-___-___-___-___-___-_-___-_-_-___-_-___-_-___-_-___-___-_-___-_-___-_
[ 219.457571] -----------------_________-_-_-_-_-_-_-_-_-___-___-___-___-___-___-___-___-_-___-_-___-_-_-___-_-___-_-___-_-___-___-_-___-_

从上边的原始数据可以看出来,每个NEC红外协议很相似,以9毫秒的高电平、4.5毫秒的低电平开始,之后跟上一堆1和0,最后一部分才是不相同的地方。简直可以总结出NEC的协议格式是这样的:

<帧头9ms高+4.5低><8位地址码><8位地址码取反><8位指令码><8位指令码取反>

没错,就是这样的😁,不过需要注意,NEC协议采用PWM(脉宽调制)编码,一个脉冲周期表示一个bit,是0还是1取决于占空比。不信请看下图:

NEC协议编码说明

⚠️我在程序中对接收到的数据取反,所以原始数据和上图的逻辑刚好相反。

结合原始数据和图片可以总结出:

  1. 协议帧头总是以9ms的高电平和4.5ms的低电平为一个脉冲周期
  2. 协议内容的脉冲周期,‘-___’表示1,‘-_’表示0,且电平信号以560us为单位;
  3. 9ms高电平和2.25ms的低电平表示重复码,即长按按键时触发
  4. 帧间间隔为110ms

红外接收电路

红外接收管树莓派接线图

如上图所示,红外接收管从左到右一共3个脚,分别是:地、3.3V、数据输出。所以供电就用树莓派自身的3.3V即可,而数据输出脚,我这里接的是GPIO18。

驱动实现

正如前文所述,NEC红外协议是高频脉冲信号,所以我用GPIO的中断来记录每一次脉冲信号及其时长。实现起来没什么太复杂的地方,大致流程为:

  1. 申请并注册GPIO18的中断,务必是双边沿触发
  2. 申请一个定时器用于超时断帧处理
  3. 每次中断触发,都记录上升或者下降沿的状态及时长
  4. 每当经过一个完整脉冲后,通过占空比判断数据类型
  5. 每当记录了32个数据(一帧)后,处理协议指令
  6. 我是直接把地址和指令推给用户层处理

⚠️注意:以下代码有个很大的风险,为了简化程序,IRQ中断我并没有采取“底半部”来处理复杂的红外解码业务,如果业务逻辑进一步加大,可能会导致内核崩溃。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/uaccess.h>
#include <linux/miscdevice.h>
#include <linux/gpio.h>
#include <linux/interrupt.h>
#include <linux/timer.h>
#include <linux/wait.h>

MODULE_LICENSE("Dual BSD/GPL");
MODULE_AUTHOR("Philon | https://ixx.life");

static struct {
int gpio;
int irq;
wait_queue_head_t rwait;
struct timer_list timer;
u32 pulse; // 脉冲上升沿持续时长
u32 space; // 脉冲下降沿持续时长
size_t count; // 脉冲个数
u32 data; // 脉冲解码后的值
} ir;

#define is_head(p, s) (p > 8900 && p < 9100 && s > 4400 && s < 4600)
#define is_repeat(p, s) (p > 8900 && p < 9100 && s > 2150 && s < 2350)
#define is_bfalse(p, s) (p > 500 && p < 650 && s > 500 && s < 650)
#define is_btrue(p, s) (p > 500 && p < 650 && s > 1500 && s < 1750)

// 红外接收函数(即GPIO18的双边沿中断处理函数)
// 记录GPIO每次中断是“上升还是下降”,以及持续的时长
static irqreturn_t ir_rx(int irq, void* dev) {
static ktime_t last = 0;
u32 duration = (u32)ktime_to_us(ktime_get() - last);

// ⚠️注意:1838红外头高低电平逻辑取反
if (!gpio_get_value(ir.gpio)) {
ir.space = duration;
} else {
// 切换下降沿时,脉冲只有高电平部分,所以不做处理
ir.pulse = duration;
goto irq_out;
}

if (is_head(ir.pulse, ir.space)) {
ir.count = ir.data = 0;
} else if (is_repeat(ir.pulse, ir.space)) {
ir.count = 32;
} else if (is_btrue(ir.pulse, ir.space)) {
ir.data |= 1 << ir.count++;
} else if (is_bfalse(ir.pulse, ir.space)) {
ir.data |= 0 << ir.count++;
} else {
goto irq_out;
}

if (ir.count >= 32) {
wake_up(&ir.rwait);
}

irq_out:
mod_timer(&ir.timer, jiffies + (HZ / 10));
last = ktime_get();
return IRQ_HANDLED;
}

// 定时清除红外协议帧的相关信息,便于接收下一帧
static void clear_flag(struct timer_list *timer) {
ir.pulse = 0;
ir.space = 0;
ir.count = 0;
ir.data = 0;
}

static ssize_t ir_read(struct file *filp, char __user *buf, size_t len, loff_t *off) {
int rc = 0;

if ((filp->f_flags & O_NONBLOCK) && ir.count < 32) {
return -EAGAIN;
} else {
DECLARE_WAITQUEUE(wq, current);
add_wait_queue(&ir.rwait, &wq);
wait_event(ir.rwait, ir.count == 32);
remove_wait_queue(&ir.rwait, &wq);
}

rc = copy_to_user(buf, &ir.data, sizeof(u32));
if (rc < 0) {
return rc;
}

ir.count = 0;
*off += sizeof(u32);
return sizeof(u32);
}

static const struct file_operations fops = {
.owner = THIS_MODULE,
.read = ir_read,
};

static struct miscdevice irdev = {
.minor = MISC_DYNAMIC_MINOR,
.name = "IR1838-NEC",
.fops = &fops,
.nodename = "ir0",
.mode = 0744,
};

static int __init ir_init(void) {
int rc = 0;

// 初始化脉冲处理函数
init_waitqueue_head(&ir.rwait);

// 初始化定时器,用于断帧
timer_setup(&ir.timer, clear_flag, 0);
add_timer(&ir.timer);

// 申请GPIO及其双边沿中断
ir.gpio = 18;
if ((rc = gpio_request_one(ir.gpio, GPIOF_IN, "IR")) < 0) {
printk(KERN_ERR "ERROR%d: can not request gpio%d\n", rc, ir.gpio);
return rc;
}

ir.irq = gpio_to_irq(ir.gpio);
if ((rc = request_irq(ir.irq, ir_rx,
IRQF_TRIGGER_RISING | IRQF_TRIGGER_FALLING,
"IR", NULL)) < 0) {
printk(KERN_ERR "ERROR%d: can not request irq\n", ir.irq);
return rc;
}

if ((rc = misc_register(&irdev)) < 0) {
return rc;
}

return 0;
}
module_init(ir_init);

static void __exit ir_exit(void) {
misc_deregister(&irdev);
free_irq(ir.irq, NULL);
gpio_free(ir.gpio);
del_timer(&ir.timer);
}
module_exit(ir_exit);

以下是应用层的测试代码,有关CAR-MP3遥控器的指令码网上一搜一大把,如果你不嫌烦,也可以一个一个的试出来。

由于驱动层是直接把原始数据的<地址><地址取反><指令><指令取反>高低位反转后,直接给到进程,所以进程read出来的数据,指令码应该在第3段(16-24位)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/fcntl.h>

// car-mp3遥控器指令码
static const char* keyname[] = {
[0x45] = "Channel-", [0x46] = "Channel", [0x47] = "Channel+",
[0x44] = "Speed-", [0x40] = "Speed+", [0x43] = "Play/Pause",
[0x15] = "Vol+", [0x07] = "Vol-", [0x09] = "EQ",
[0x16] = "No.0", [0x19] = "100+", [0x0d] = "200+",
[0x0c] = "No.1", [0x18] = "No.2", [0x5e] = "No.3",
[0x08] = "No.4", [0x1c] = "No.5", [0x5a] = "No.6",
[0x42] = "No.7", [0x52] = "No.8", [0x4a] = "No.9",
};

int main(int argc, char* argv[]) {
int ir = open("/dev/ir0", O_RDONLY);

while (1) {
int frame = 0;
if (read(ir, &frame, sizeof(int)) < 0) {
perror("read ir");
break;
}

int cmd = (frame >> 16) & 0xFF;
printf("%s\n", keyname[cmd]);
}

close(ir);
return 0;
}

小结

  • NEC协议采用PWM编码,一个完整的脉冲周期表示一个bit
  • 1838红外接收头状态取反
  • 别看我写的这么轻松,前几天刚接触红外时简直被搞疯😫