Golang笔记–04–GPIO与中断

前言

最初动笔时,想着把那次涉及到MySQL的故障一起写下,可是搜集资料后,发现关于中断、轮询还有GPIO就可以写很多的内容,全面展开的话大概又会陷入持久战无法产出。刚开始写GPIO和串口代码时,我认为应该有一个库提供这样的功能:检测引脚的电平变化或者边沿变化,当数值改变时读取串口数据,有点像iOS中的键值观察。用到的第一个开源库并没有实现这样的功能,需要主动轮询来检测上升沿和下降沿,上一篇中也有实例代码,每10ms检查一次是否有上升沿或者下降沿,只是碰到检查间隔与硬件触发时间点冲突时,会少收或者多收到数据,在树莓派3上正常运作,但在树莓派 zero w上却出现兼容性问题,随后切换到了另一个库,它的实现就比较符合预想的功能,执行SELECT系统调用,检测到边沿触发时通知用户。

GPIO

上一篇链接:Golang笔记–03

首先是用到的开源库和代码,上一篇中使用的是
github.com/stianeikeland/go-rpio

查看README和源码,可以看到它通过/dev/mem或者/dev/gpiomem访问引脚,将内存地址转换为指针主动访问,带来的问题也是无法接收到电平变化的通知,需要主动轮询,此外在树莓派zero w上会引起死机。

PS: 找到了github.com/brian-armstrong/gpio这个库在树莓派zero w上死机的原因了,问题出在取地址偏移的函数,它没有像RPI.GPIO一样做好错误处理,详情加到issue了:Raspberry Pi Zero W compatibility issue

PS: 再度更新,找到bug了,GOARM环境变量在macOS上默认为7,树莓派2代以及3代更新了CPU,支持ARMv7指令集并向下兼容,而树莓派A, A+, B, B+, Zero的CPU还是老款ARMv6的,之所以遇到问题,是因为在官方镜像带的内核上,几乎无法编译出可执行程序,无限卡死,只能先交叉编译后再拷贝过去,然后又忘了指定GOARM……更新内核后就可以在树莓派zero w上编译了

这次使用的是github.com/brian-armstrong/gpio

通过在/sys/class/gpio目录下,为每个使用的引脚创建索引,使用文件描述符来进行GPIO操作,通过SELECT系统调用获取电平与边沿变化,上一篇中相关代码修改如下

全局变量

const (
	io1    = uint(5)
	rst    = uint(13)
	intIn  = uint(26)
	intOut = uint(21)
)
// 对串口的引用
var s *serial.Port
// 对inPin的引用
var inPin *gpio.Pin
// 对gpio监视器的引用
var watcher *gpio.Watcher

初始化串口

与上一篇保持一致,设置了读超时时长

func startSeril() {
	c := &serial.Config{Name: "/dev/ttyAMA0", Baud: 115200, ReadTimeout: time.Second * 5}
	p, err := serial.OpenPort(c)
	s = p
	if err != nil {
		log.Fatal(err)
	}
	log.Info("open serial port successfully")
}

初始化GPIO

在main函数中调用,程序退出前关闭监视器

// 设置低电平输出
gpio.NewOutput(io1, false)
// 设置高电平输出
gpio.NewOutput(rst, true)
// 设置为输入模式
p := gpio.NewOutput(intIn, true)
inPin = &p
watcher = gpio.NewWatcher()
defer watcher.Close()
// 检测下降沿,低电平有效
watcher.AddPinWithEdgeAndLogic(intOut, gpio.EdgeFalling, gpio.ActiveLow)
// 在goroutine中读取引脚状态
go func() {
	for {
		pin, value := watcher.Watch()
		if pin == intOut && value == 0 {
			read()
		}
	}
}()

读串口数据

新的库使用了系统调用来获取状态变化,感觉灵敏度有所提升,例如在初始化模块后,模块会立刻返回响应,初始化后返回一个数据包,树莓派3上两个数据连在一起返回,而树莓派 zero w上可以读取到分开的数据,可能与硬件的性能有关,这里根据起始结束符做了特殊处理。由于是从系统缓冲区读取数据,可能会遇到下发的命令响应数据与采集到的数据连接在一块发送的情况,需要根据协议定义起始符和结束符来拆分。这次有点麻烦是命令相应的结束符可能出现在数据中,设计协议时应该尽量避免这种情况。

func read() {
	data := []byte{}
	for {
		buf := make([]byte, 50)
		n, err := s.Read(buf)
		if err != nil {
			log.WithField("err", err).Info("read error")
			break
		}
		dataRead := buf[:n]
		data = append(data, dataRead...)
		if dataRead[n-1] == byte(0x21) {
			str := string(data)
			log.Info("RESP: ", str)
			pushData(str)
			break
		}
		if dataRead[n-1] == byte(0xfe) {
			if len(data) > 20 {
				preDecodeBytes(data)
			}
			break
		}
	}
	s.Flush()
}

初始化模块

初始化模块操作与之前相同,拉低电平,2ms后拉高来触发一个下降沿,休眠80ms后发送数据

func sendCMD(cmd string) bool {
	result := false
	inPin.Low()
	time.Sleep(time.Millisecond * 2)
	inPin.High()
	time.Sleep(time.Millisecond * 80)
	for i := 0; i < 3; i++ {
		data := []byte(cmd)
		_, err := s.Write(data)
		if err != nil {
			log.WithField("err", err).Info("failed to send cmd: " + cmd)
			time.Sleep(time.Millisecond * 2)
		} else {
			log.Info("REQ: " + cmd)
			result = true
			break
		}
	}
	return result
}

新的开源库在编程和直观的感觉上都会给人一种省电的感觉,毕竟用户空间的操作在接收到通知后才会执行,逻辑上也更清晰,实际测试下来的功耗并没有太多的改变,换一个角度来讲,是操作系统代替了用户轮询的操作,除了使用SELECT外,还有EPOLL(Linux),KQUEUE(Unix)等操作系统提供的系统调用可以使用,常用的RPI.GPIO库,就是在底层使用EPOLL获取引脚状态变化,再封装为Python调用,接下来先讨论文件描述符。

文件描述符

在Unix/Linux中,一切皆文件,几乎所有的操作都是对文件的操作,这次用到库是根据这篇文档实现的:GPIO Sysfs Interface for Userspace

在/sys/class/gpio中可以看到上述代码里打开的引脚

用ps命令获取到程序的PID,在对应的/proc目录下可以看到当前程序的信息

其中fd目录保存着该程序打开的文件描述符

Linux上默认每个进程最多打开1024个描述符,采集程序需要同时开启API并通过HTTP请求上报数据,之前没有正确关闭HTTP请求中的body,导致文件描述符很快被占用完,程序无法上报数据,也不受理HTTP请求,因此需要做好文件描述符的打开和释放,否则即使通过配置增加上限也无法保证程序能长期运行。

SELECT、EPOLL与中断

我们知道用户空间是无法直接操作硬件,只能通过系统调用让操作系统经驱动程序控制硬件,这里涉及到用户态与内核态的切换。

硬件中断是异步的,操作系统接收到硬件中断后,处理硬件发送的电信号并转换为数据,用户读取数据时,从内核空间拷贝到用户空间。

上面的示例程序启动后,通过cat命令查看当前系统中断信息,可以看到正在监控的21号引脚。

在搜索SELECT与EPOLL时,绝大部分内容是关于网络IO问题,有些解答甚至把底层实现都详细解说了一遍,涉及内核调优、文件系统、内存映射、内核态与用户态切换以及大量的数据结构和算法优化,而这里的场景是树莓派上的硬件IO,需要监控的引脚只有一个,但要求实时性高且负载低、省电。

github.com/brian-armstrong/gpio完全使用文件描述符操作GPIO,边沿检测也是利用SELECT系统调用,通过文件描述符监控,而github.com/brian-armstrong/gpio以及RPI.GPIO则使用内存地址偏移来读写引脚数据,但前者只能使用主动轮询引脚数值实现监控,后者使用EPOLL通过文件描述符监控。

处于用户态的采集程序如果使用主动轮询,相当于持续地发起系统调用,从用户态触发串口读取,而使用SELECT或者EPOLL时,由处于内核态的操作系统主动通知用户读取串口。因为硬件中断是异步的,主动轮询的间隔越短,获取到事件通知的实时性越高,相应会占用更多的CPU时间,也消耗更多的电力。

这里使用到的外接模块,其事件触发与数据发送周期都是毫秒级,而树莓派3和树莓派zero w的CPU时钟周期是纳秒级,开头使用10ms的轮询间隔也是实际测试后挑选出的,采集到的数据基本都是正常的,但总会遇到这样的情况:轮询操作刚刚进入休眠时,硬件被环境触发并开始发送数据,但休眠结束时才读数据,遇到CPU压力增大时,从休眠回复的时间可能更长,此时会出现数据异常,少收或者多收到数据。

如果关键数据正好因此被处理为异常数据,采集再多也没有意义,这是最初想要解决的问题,也希望SELECT或者EPOLL减少轮询次数与系统调用,节约电池电量,但结果其实并没有太多差别,倒是让直观上的逻辑更清晰了一些。切换到新的开源库后:

(1)数据的实时性有所提升,例如初始化流程,先下发命令,模块响应,接着进行初始化并返回一个数据。使用主动轮询时,固定存在10ms以上的间隔,通过串口读取的数据包也是分割开的,而使用系统调用后,在树莓派3上经常出现命令响应与采集数据连接在一起返回的情况,因此在解析数据的操作里增加了预处理操作,通过结束符分隔两个数据包;

(2)电量消耗并没有下降,每个设备启动都有最低功耗,显然采集程序与外置模块并不是最大的电量消耗原因,以树莓派zero w为例,电流维持在100~110mA之间,如果每秒采集一个数据,使用四节容量2600mAh的18650并联电池组通过升压模块供电,可以运作29个小时左右。

GPIO库结构

这里浏览一遍github.com/brian-armstrong/gpio的主要内容:

(1)代码结构

io.go,打开或关闭引脚,设置引脚的输入输出模式、高低电平有效状态、输出模式下设置引脚高低电平;

sysfs.go,实现了用户空间的GPIO调用,参照上一节的文档链接与截图内容;

watcher.go,监视器,在内部维护一个heap,保存需要监控的引脚,发起SELECT系统调用,当引脚产生事件时返回;

select_darwin.go及select_linux.go,封装了两个平台下的SELECT系统调用。

(2)读写引脚

// 引脚结构,包含编号、输入输出方向、文件描述符引用
type Pin struct {
	Number    uint
	direction direction
	f         *os.File
}
// 首先导出引脚
func exportGPIO(p Pin) {
	export, err := os.OpenFile("/sys/class/gpio/export", os.O_WRONLY, 0600)
	if err != nil {
		fmt.Printf("failed to open gpio export file for writing\n")
		os.Exit(1)
	}
	defer export.Close()
	export.Write([]byte(strconv.Itoa(int(p.Number))))
}
// 也可以使用echo命令手动导出
echo 21 > /sys/class/gpio/export
// 设置输入输出方向及初始值
func setDirection(p Pin, d direction, initialValue uint) {
	dir, err := os.OpenFile(fmt.Sprintf("/sys/class/gpio/gpio%d/direction", p.Number), os.O_WRONLY, 0600)
	if err != nil {
		fmt.Printf("failed to open gpio %d direction file for writing\n", p.Number)
		os.Exit(1)
	}
	defer dir.Close()

	switch {
	case d == inDirection:
		dir.Write([]byte("in"))
	case d == outDirection && initialValue == 0:
		dir.Write([]byte("low"))
	case d == outDirection && initialValue == 1:
		dir.Write([]byte("high"))
	default:
		panic(fmt.Sprintf("setDirection called with invalid direction or initialValue, %d, %d", d, initialValue))
	}
}
// 文件描述符,边沿触发监控该文件描述符的数值变化,同时需要根据电平初始值及需要检测的边沿设置高低电平有效状态
func openPin(p Pin, write bool) Pin {
	flags := os.O_RDONLY
	if write {
		flags = os.O_RDWR
	}
	f, err := os.OpenFile(fmt.Sprintf("/sys/class/gpio/gpio%d/value", p.Number), flags, 0600)
	if err != nil {
		fmt.Printf("failed to open gpio %d value file for reading\n", p.Number)
		os.Exit(1)
	}
	p.f = f
	return p
}
// 电平有效状态,在边沿检测时设置,例如初始状态为高电平,检测下降沿时需要设置为低电平有效,才能在检测到时返回
func setLogicLevel(p Pin, l LogicLevel) error {
	level, err := os.OpenFile(fmt.Sprintf("/sys/class/gpio/gpio%d/active_low", p.Number), os.O_WRONLY, 0600)
	if err != nil {
		return err
	}
	defer level.Close()

	switch l {
	case ActiveHigh:
		level.Write([]byte("0"))
	case ActiveLow:
		level.Write([]byte("1"))
	default:
		return errors.New("Invalid logic level setting.")
	}
	return nil
}
// 读写引脚,即对文件描述符的读写
func readPin(p Pin) (val uint, err error) {
	file := p.f
	file.Seek(0, 0)
	buf := make([]byte, 1)
	_, err = file.Read(buf)
	if err != nil {
		return 0, err
	}
	c := buf[0]
	switch c {
	case '0':
		return 0, nil
	case '1':
		return 1, nil
	default:
		return 0, fmt.Errorf("read inconsistent value in pinfile, %c", c)
	}
}
func writePin(p Pin, v uint) error {
	var buf []byte
	switch v {
	case 0:
		buf = []byte{'0'}
	case 1:
		buf = []byte{'1'}
	default:
		return fmt.Errorf("invalid output value %d", v)
	}
	_, err := p.f.Write(buf)
	return err
}

(3)边沿触发

// 包含监听的引脚及对应文件描述符
// 增删引脚时同步到heap
// 使用channel同步数据,所以在第一部分的初始化GPIO中需要开启一个goroutine来执行回调
type Watcher struct {
	pins         map[uintptr]Pin
	fds          fdHeap
	cmdChan      chan watcherCmd
	Notification chan WatcherNotification
}
// 启动监听,当引脚数量大于0时执行SELECT系统调用,循环调用
func (w *Watcher) watch() {
	for {
		// first we do a syscall.select with timeout if we have any fds to check
		if len(w.fds) != 0 {
			w.fdSelect()
		} else {
			// so that we don't churn when the fdset is empty, sleep as if in select call
			time.Sleep(1 * time.Second)
		}
		if w.recv() == false {
			return
		}
	}
}
// SELECT系统调用,任何一个监控中的引脚数值发生变化时返回
func (w *Watcher) fdSelect() {
	timeval := &syscall.Timeval{
		Sec:  1,
		Usec: 0,
	}
	fdset := w.fds.FdSet()
	changed, err := doSelect(int(w.fds[0])+1, nil, nil, fdset, timeval)
	if err != nil {
		fmt.Printf("failed to call syscall.Select, %s", err)
		os.Exit(1)
	}
	if changed {
		w.notify(fdset)
	}
}
// 遍历全部监控的引脚数值,封装为一个通知对象传递给用户
func (w *Watcher) notify(fdset *syscall.FdSet) {
	for _, fd := range w.fds {
		if (fdset.Bits[fd/64] & (1 << (uint(fd) % 64))) != 0 {
			pin := w.pins[fd]
			val, err := pin.Read()
			if err != nil {
				if err == io.EOF {
					w.removeFd(fd)
					continue
				}
				fmt.Printf("failed to read pinfile, %s", err)
				os.Exit(1)
			}
			msg := WatcherNotification{
				Pin:   pin.Number,
				Value: val,
			}
			select {
			case w.Notification <- msg:
			default:
			}
		}
	}
}

最后

最后的最后,其实在写这篇博客的过程里,看了许许多多关于IO多路复用的资料,这里一并贴上链接:

Linux IO模式及 select、poll、epoll详解

select、poll、epoll之间的区别总结[整理]

大话 Select、Poll、Epoll

select和epoll 原理概述&优缺点比较

这些资料对于从懵逼状态过渡到有一点思路还是很有帮助的,想要更加深入一些,那就看源码吧,这里以Linux系统为例,可以下载最新版的源码(目前是4.17.11):kernel.org

SELECT及POLL的实现:fs/select.c

EPOLL的实现:fs/eventpoll.c

另外对于EPOLL的实现有一份英文博客可以参考,作者写得十分详细,用四篇的幅度将EPOLL解析透了:

the-implementation-of-epoll-1

the-implementation-of-epoll-2

the-implementation-of-epoll-3

the-implementation-of-epoll-4

发表评论

电子邮件地址不会被公开。 必填项已用*标注