版权声明

本文档所有内容文字资料由凌云实验室郭工编著,主要用于凌云嵌入式Linux教学内部使用,版权归属作者个人所有。任何媒体、网站、或个人未经本人协议授权不得转载、链接、转帖或以其他方式复制发布/发表。已经授权的媒体、网站,在下载使用时必须注明来源,违者本人将依法追究责任。

  • Copyright (C) 2021 凌云物网智科实验室·郭工

  • Author: GuoWenxue <guowenxue@gmail.com> QQ: 281143292

wechat_pub

2.1.1 交叉编译介绍

在 X86 架构 Linux 系统下进行 C 程序开发时, 我们使用系统的 gcc 编译器进行代码的编译, 编译生成的可执行程序直接在 X86 架构下的 PC 下运行的,这个过程叫做 本地编译 (Native Compile) 。 而如果该C程序想要编译出来后放到ARM处理器架构的系统上运行, 则需要在 X86 架构Linux系统下使用支持 ARM 的编译器编译, 这个编译器我们通常称为 交叉编译器 (Cross Compiler)。

而在一种平台上编译出能在另外一种体系结构完全不同处理器上运行程序的编译过程,叫做 交叉编译 (Cross Compile)。比如在PC平台(X86 CPU)上编译出能运行在以ARM为内核的CPU平台上的程序,编译得到的程序在X86 CPU平台上是不能运行的,必须放到ARM CPU平台上才能运行,虽然两个平台用的都是Linux系统。

cross_compile

交叉编译工具链是一个由编译器、连接器和解释器组成的综合开发环境,交叉编译工具链主要由binutils、gcc和glibc三个部分组成。有时出于减小 libc 库大小的考虑,也可以用别的 c 库来代替 glibc,例如 uClibc 或 newlib。

之所以几乎所有的ARM开发板开发都选择交叉编译,这是因为这些开发板生产出来后并没有系统,这时需要在PC上使用交叉编译器交叉编译操作系统源码,为它构建一个完整的 Linux 系统。另外,由于CPU处理能力、外存和内存存储空间的大小限制,它们不足以能够运行 gcc 编译环境,所以嵌入式开发绝大部分的过程都是交叉编译。

2.1.2 常用的交叉编译器

Ubuntu系统交叉编译器

Ubuntu之所以能成为嵌入式系统开发的首选Linux发行版本,正是因为它的软件包在线安装仓库中包含有海量的开发工具/软件,其中就包括嵌入式系统开发所需的交叉编译器、dtc等开发工具,这也就是为什么几乎所有主流的半导体厂商在发布SDK时都推荐使用Ubuntu系统。

在Ubuntu 系统中提供了如下四个版本的交叉编译器软件包:

  • gcc-arm-linux-gnueabi —- armel,ARM EABI Little-endian

  • gcc-arm-linux-gnueabihf —- armhf,ARM Hard Float

  • gcc-aarch64-linux-gnu —- arm64,用于编译64位ARM处理器系统

  • gcc-arm-none-eabi —- bare metal, 用于编译ARM架构的单片机程序,使用的是newlib库

在上面的软件包中,gcc-arm-none-eabi 和 gcc-aarch64-linux-gnu 都比较好理解。而 gcc-arm-linux-gnueabi 与 gcc-arm-linux-gnueabihf 有什么区别呢?这就涉及到 ARM 处理器架构中的浮点运算相关知识了。

出于低功耗、封装限制等种种原因,以前的一些ARM处理器没有独立的硬件浮点运算单元,需要使用软件来实现浮点运算。随着技术发展,现在高端的ARM处理器基本都具备了硬件执行浮点操作的能力。这样,新旧两种架构之间的差异,就产生了两个不同的嵌入式应用程序二进制接口(EABI)——软浮点(SoftFP)与矢量浮点(VFP)。但是软浮点(soft float)和硬浮点(hard float)之间有向前兼容却没有向后兼容的能力,也就是软浮点的二进制接口(EABI)仍然可以用于当前的高端ARM处理器。

ARM-fpu

在ARM体系架构内核中,有些有浮点运算单元(fpu,floating point unit),有些没有。对于没有fpu内核,是不能使用armel和armhf的。在有fpu的情况下,就可以通过gcc的选项-mfloat-abi来指定使用哪种,有如下四种值:

  1. soft:不用fpu计算,即使有fpu浮点运算单元也不用。

  2. armel:(arm eabi little endian)也即softfp,用fpu计算,但是传参数用普通寄存器传,这样中断的时候,只需要保存普通寄存器,中断负荷小,但是参数需要转换成浮点的再计算。

  3. armhf:(arm hard float)也即hard,用fpu计算,传参数用fpu中的浮点寄存器传,省去了转换性能最好,但是中断负荷高。

  4. arm64:64位的arm默认就是hard float的,因此不需要hf的后缀。

在之前的EABI中,armel(低端ARM硬件)在执行浮点运算之前,浮点参数必须首先通过整数寄存器,然后传递到浮点运算单元。新的EABI ,也就是armhf,通过直接传递参数到浮点寄存器优化了浮点运算的调用约定。相比我们熟悉的armel,armhf代表了另一种不兼容的二进制标准。

在一些社区的支持下,armhf目前已经得到了很大的发展。像Ubuntu,已经计划在之后的发行版中放弃armel,转而支持armhf编译的版本。正如目前依然很火热的Raspberry Pi(早期版本使用ARM11),由于ubuntu只支持armv7架构的编译,Raspberry Pi将不能直接安装ubuntu系统。而 BB Black(Cortex-A8)和 Cubietruct(Cortex-A7)则同时支持ubuntu的armel与armhf的编译。

kernel、rootfs和app编译的时候,指定的必须保持一致才行。使用softfp模式,会存在不必要的浮点到整数、整数到浮点的转换。而使用hard模式,在每次浮点相关函数调用时,平均能节省20个CPU周期。对ARM这样每个周期都很重要的体系结构来说,这样的提升无疑是巨大的。在完全不改变源码和配置的情况下,在一些应用程序上,虽然armhf比armel硬件要求(确切的是指fpu硬件)高一点,但是armhf能得到20-25%的性能提升。对一些严重依赖于浮点运算的程序,更是可以达到300%的性能提升。

因为我们的 i.MX6ULL 处理器带有硬件浮点FPU,所以这里我们选择安装硬浮点交叉编译器。下面安装的 gcc 的是 C 程序编译器,而 g++ 则是 C++ 程序的编译器。

guowenxue@ubuntu20:~$ sudo apt install -y gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf

guowenxue@ubuntu20:~$ arm-linux-gnueabihf-gcc -v
Using built-in specs.
COLLECT_GCC=arm-linux-gnueabihf-gcc
COLLECT_LTO_WRAPPER=/usr/lib/gcc-cross/arm-linux-gnueabihf/9/lto-wrapper
Target: arm-linux-gnueabihf
Configured with: ../src/configure -v --with-pkgversion='Ubuntu 9.4.0-1ubuntu1~20.04' --with-bugurl=file:///usr/share/doc/gcc-9/README.Bugs --enable-languages=c,ada,c++,go,d,fortran,objc,obj-c++,gm2 --prefix=/usr --with-gcc-major-version-only --program-suffix=-9 --enable-shared --enable-linker-build-id --libexecdir=/usr/lib --without-included-gettext --enable-threads=posix --libdir=/usr/lib --enable-nls --with-sysroot=/ --enable-clocale=gnu --enable-libstdcxx-debug --enable-libstdcxx-time=yes --with-default-libstdcxx-abi=new --enable-gnu-unique-object --disable-libitm --disable-libquadmath --disable-libquadmath-support --enable-plugin --enable-default-pie --with-system-zlib --without-target-system-zlib --enable-libpth-m2 --enable-multiarch --enable-multilib --disable-sjlj-exceptions --with-arch=armv7-a --with-fpu=vfpv3-d16 --with-float=hard --with-mode=thumb --disable-werror --enable-multilib --enable-checking=release --build=x86_64-linux-gnu --host=x86_64-linux-gnu --target=arm-linux-gnueabihf --program-prefix=arm-linux-gnueabihf- --includedir=/usr/arm-linux-gnueabihf/include
Thread model: posix
gcc version 9.4.0 (Ubuntu 9.4.0-1ubuntu1~20.04) 

ARM官方交叉编译器

在NXP的官方开发文档中,其推荐使用ARM官方提供的交叉编译器。为与此前各版本Linux系统移植所用交叉编译器保持兼容,我们选择 gcc-arm-10.3-2021.07 这个版本,点此链接进入其下载页面。

因为我们是在 X86_64 位 Ubuntu 服务器来做开发,而 IMX6ULL 处理器是带硬件浮点的 32位处理器,所以我们需要下载 gcc-arm-10.3-2021.07-x86_64-arm-none-linux-gnueabihf.tar.xz 这个文件。

guowenxue@ubuntu20:~$ wget https://developer.arm.com/-/media/Files/downloads/gnu-a/10.3-2021.07/binrel//gcc-arm-10.3-2021.07-x86_64-arm-none-linux-gnueabihf.tar.xz

接下来我们将其解压缩并安装到 /opt 路径下,并重命名为 gcc-aarch32-10.3-2021.07

guowenxue@ubuntu20:~$ sudo tar -xJf gcc-arm-10.3-2021.07-x86_64-arm-none-linux-gnueabihf.tar.xz -C /opt/

guowenxue@ubuntu20:~$ sudo mv /opt/gcc-arm-10.3-2021.07-x86_64-arm-none-linux-gnueabihf/ /opt/gcc-aarch32-10.3-2021.07/

使用下面命令可以查看所安装的交叉编译器版本信息。

guowenxue@ubuntu20:~$ /opt/gcc-aarch32-10.3-2021.07/bin/arm-none-linux-gnueabihf-gcc -v
Using built-in specs.
COLLECT_GCC=/opt/gcc-aarch32-10.3-2021.07/bin/arm-none-linux-gnueabihf-gcc
COLLECT_LTO_WRAPPER=/opt/gcc-aarch32-10.3-2021.07/bin/../libexec/gcc/arm-none-linux-gnueabihf/10.3.1/lto-wrapper
Target: arm-none-linux-gnueabihf
Configured with: /data/jenkins/workspace/GNU-toolchain/arm-10/src/gcc/configure --target=arm-none-linux-gnueabihf --prefix= --with-sysroot=/arm-none-linux-gnueabihf/libc --with-build-sysroot=/data/jenkins/workspace/GNU-toolchain/arm-10/build-arm-none-linux-gnueabihf/install//arm-none-linux-gnueabihf/libc --with-bugurl=https://bugs.linaro.org/ --enable-gnu-indirect-function --enable-shared --disable-libssp --disable-libmudflap --enable-checking=release --enable-languages=c,c++,fortran --with-gmp=/data/jenkins/workspace/GNU-toolchain/arm-10/build-arm-none-linux-gnueabihf/host-tools --with-mpfr=/data/jenkins/workspace/GNU-toolchain/arm-10/build-arm-none-linux-gnueabihf/host-tools --with-mpc=/data/jenkins/workspace/GNU-toolchain/arm-10/build-arm-none-linux-gnueabihf/host-tools --with-isl=/data/jenkins/workspace/GNU-toolchain/arm-10/build-arm-none-linux-gnueabihf/host-tools --with-arch=armv7-a --with-fpu=neon --with-float=hard --with-mode=thumb --with-arch=armv7-a --with-pkgversion='GNU Toolchain for the A-profile Architecture 10.3-2021.07 (arm-10.29)'
Thread model: posix
Supported LTO compression algorithms: zlib
gcc version 10.3.1 20210621 (GNU Toolchain for the A-profile Architecture 10.3-2021.07 (arm-10.29)) 

因为该交叉编译器并没有安装到Linux的标准系统路径下,这样每次使用交叉编译器时都要使用绝对路径。我们可以在 Bash Shell 的默认配置文件 ~/.bashrc 中,将交叉编译器的路径添加到 PATH 环境变量中去。

guowenxue@ubuntu20:~$ vim ~/.bashrc 
export PATH=$PATH:/opt/gcc-aarch32-10.3-2021.07/bin/

重启 Shell 或者使用 source 命令使配置文件生效后,就可以不用绝对路径直接使用交叉编译器了。

guowenxue@ubuntu20:~$ source ~/.bashrc 

guowenxue@ubuntu20:~$ arm-none-linux-gnueabihf-gcc -v
Using built-in specs.
... ...
gcc version 10.3.1 20210621 (GNU Toolchain for the A-profile Architecture 10.3-2021.07 (arm-10.29)) 

2.1.3 交叉编译测试

接下来我们以大家熟悉的 hello world 程序为例,讲解嵌入式交叉编译过程。首先,我们创建IGKBoard应用接口编程的工作目录如下,今后所有的应用程序都放到该路径下。

guowenxue@ubuntu20:~$ mkdir -p igkboard/apps/
guowenxue@ubuntu20:~$ cd igkboard/apps/

2.1.3.1 本地编译运行hello程序

首先,我们使用 vim 编辑器编写 hello.c 测试程序:

guowenxue@ubuntu20:~/igkboard/apps$ vim hello.c
/*********************************************************************************
 *      Copyright:  (C) 2021 LingYun IoT System Studio
 *                  All rights reserved.
 *
 *       Filename:  hello.c
 *    Description:  This file is hello world test program.
 *                 
 *        Version:  1.0.0(2021-12-10)
 *         Author:  Guo Wenxue <guowenxue@gmail.com>
 *      ChangeLog:  1, Release initial version on "2021-12-10 22:41:49"
 *                 
 ********************************************************************************/

#include <stdio.h>

int main (int argc, char **argv)
{
    printf("Hello, LingYun IoT System Studio.\n");

    return 0;
} 

我们知道,C程序必须要使用编译器编译生成可执行程序,才能执行。接下来我们使用 Linux服务器上的 gcc 编译该程序并运行:

guowenxue@ubuntu20:~/igkboard/apps$ gcc hello.c -o hello

guowenxue@ubuntu20:~/igkboard/apps$ ./hello 
Hello, LingYun IoT System Studio.

我们可以使用 file 命令查看 hello 程序的相关信息,由此可知该程序应该在X86-64位的系统上运行:

guowenxue@ubuntu20:~/igkboard/apps$ file hello
hello: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=848c2886fa91f39e64ed699ed02c11ed532cf760, for GNU/Linux 3.2.0, not stripped

接下来我们在IGKBoard开发板上使用 scp 命令,将服务器上的程序拷贝到开发板上来运行试试。

root@igkboard:~# scp -P 2288 guowenxue@192.168.2.2:~/igkboard/apps/hello .
guowenxue@192.168.2.2's password: 
hello                                         100%   13KB 758.4KB/s   00:00   
  • scp 是SSH中最方便有用的命令了,他可以在两个系统之间通过SCP协议直接传送文件;

  • -P 2288 如果目标服务器的 SSH2 协议端口不是默认的22号端口,则需要通过该选项指定端口号;

  • guowenxue@192.168.2.2 这是 SSH2 协议登录的服务器地址和用户名;

  • :~/hello 此后跟冒号(:) 并加路径名表示要拷贝的目标文件,这里是指 guowenxue 这个账号主目录(~)下的 hello 文件。如果是文件夹的话,注意scp命令要加上 -r 选项。

  • . 表示将目标文件或文件夹拷贝到当前路径下。

然后给该程序相应的执行权限并运行,这时系统会提示可执行文件格式出错,不能正常运行。

root@imx6ull:~# chmod a+x hello 

root@imx6ull:~# ./hello 
-bash: ./hello: cannot execute binary file: Exec format error

2.1.3.2 交叉编译运行hello程序

我们知道C程序具有可移植性,使用PC上的编译器编译生成的程序应该在PC上运行,而不能在其他处理器架构上运行,那怎样让 hello.c 程序在ARM开发板上运行呢?这里就需要使用ARM的交叉编译器来对该程序进行交叉编译,这样编译输出的可执行程序就能在ARM开发板上运行了。

接下来,我们使用Ubuntu系统的 ARM交叉编译器编译该 hello.c 程序,并尝试在 X86-64位 Linux服务器上运行,我们会发现 Linux 服务器上默认并不能运行该程序:

guowenxue@ubuntu20:~/igkboard/apps$ arm-linux-gnueabihf-gcc hello.c -o hello

guowenxue@ubuntu20:~/igkboard/apps$ ./hello 
/lib/ld-linux-armhf.so.3: No such file or directory

接下来我们使用 file 命令查看该程序的相关信息,由此可知该程序应该在ARM处理器系统上运行:

guowenxue@ubuntu20:~/igkboard/apps$ file hello
hello: ELF 32-bit LSB shared object, ARM, EABI5 version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-armhf.so.3, BuildID[sha1]=fd113c370ad4fd166fc594d3af3de0b9f36f27e4, for GNU/Linux 3.2.0, not stripped

接下来我们在IGKBoard开发板上使用 scp 命令,将服务器上的程序拷贝到开发板上来。

root@igkboard:~# scp -P 2288 guowenxue@192.168.2.2:~/igkboard/apps/hello .
guowenxue@192.168.2.2's password: 
hello                                         100%   13KB 758.4KB/s   00:00   

再给该程序相应的执行权限,就可以运行了。

root@igkboard:~# chmod a+x hello 

root@igkboard:~# ./hello 
Hello, LingYun IoT System Studio.

由此可见:

  • C程序如果想在PC上运行,则应该用PC的编译器来编译;而该程序想要在ARM开发板上运行,则必须用ARM的交叉编译器对源码重新进行交叉编译;

  • C程序具有可移植性是指,C程序源代码不用作任何的修改,使用不同的编译器编译生成的可执行程序可以在不同的处理器架构平台上运行;

每次编译都需要指定交叉编译器有点麻烦,这时候可以写一个 makefile 文件来一键编译。

guowenxue@ubuntu20:~/igkboard/apps$ vim makefile 

# Cross compiler
CROSS_COMPILE=arm-linux-gnueabihf-

all:
        ${CROSS_COMPILE}gcc ${CFLAGS} hello.c -o hello ${LDFLAGS}

clean:
        @rm -f hello

接下来再需要编译的话,只需要运行 make 命令即可。

guowenxue@ubuntu20:~/igkboard/apps$ make
arm-linux-gnueabihf-gcc  hello.c -o hello 

2.1.4 交叉编译器链介绍

通常编译工具链由编译器、链接器和解释器构成,具体到组件上是由Binutils、GCC、Glibc和GDB构成的。我们看看ARM 官方的交叉编译器链里都提供了哪些文件。

guowenxue@ubuntu20:~$ ls /opt/gcc-aarch32-10.3-2021.07/bin/
arm-none-linux-gnueabihf-addr2line  arm-none-linux-gnueabihf-elfedit     arm-none-linux-gnueabihf-gcov           arm-none-linux-gnueabihf-ld        arm-none-linux-gnueabihf-ranlib
arm-none-linux-gnueabihf-ar         arm-none-linux-gnueabihf-g++         arm-none-linux-gnueabihf-gcov-dump      arm-none-linux-gnueabihf-ld.bfd    arm-none-linux-gnueabihf-readelf
arm-none-linux-gnueabihf-as         arm-none-linux-gnueabihf-gcc         arm-none-linux-gnueabihf-gcov-tool      arm-none-linux-gnueabihf-ld.gold   arm-none-linux-gnueabihf-size
arm-none-linux-gnueabihf-c++        arm-none-linux-gnueabihf-gcc-10.3.1  arm-none-linux-gnueabihf-gdb            arm-none-linux-gnueabihf-lto-dump  arm-none-linux-gnueabihf-strings
arm-none-linux-gnueabihf-c++filt    arm-none-linux-gnueabihf-gcc-ar      arm-none-linux-gnueabihf-gdb-add-index  arm-none-linux-gnueabihf-nm        arm-none-linux-gnueabihf-strip
arm-none-linux-gnueabihf-cpp        arm-none-linux-gnueabihf-gcc-nm      arm-none-linux-gnueabihf-gfortran       arm-none-linux-gnueabihf-objcopy
arm-none-linux-gnueabihf-dwp        arm-none-linux-gnueabihf-gcc-ranlib  arm-none-linux-gnueabihf-gprof          arm-none-linux-gnueabihf-objdump

下表列出了交叉编译器链中各个工具的作用:

工具名

工具说明

gcc

C程序源码编译前端工具,它会调用Binutils提供的工具来对源码进行预处理、编译、汇编、最后链接生成可执行文件

g++

C++程序源码编译前端工具,它会调用Binutils提供的工具来对源码进行预处理、编译、汇编、最后链接生成可执行文件

cpp

C程序预处理器(C preprocessor)

as

该工具用来将汇编源码汇编成目标机器代码.o文件

ar

该工具用来将多个可重定位的.o文件归档为一个静态库.a文件

ranlib

产生归档.a文件索引,并将其保存到这个归档文件中,因为 ar 命令支持该特性,所以现在很少使用了

ld

链接器,用来将多个目标文件.o、静态库.a文件、动态库.so 文件链接生成一个可执行文件

readelf

列出 ELF 格式可执行文件的相关信息

nm

列出目标文件中的函数符号表

size

列出目标文件中每个段(text、data、bss等)的大小

strings

列出目标文件中能打印出来的字符串,如代码中的字符串常量”Hello, World”,”Password”等

strip

去掉目标文件中一些无关调试信息等,这样可以减小文件的大小

objcopy

把一种目标文件中的内容复制到另一种目标文件中,裸机开发经常会用这个命令将ELF格式的文件转换成二进制文件

objdump

该工具常用于对二进制文件进行反汇编,默认输出到标准输出,所以一般配合重定向一起使用

addr2line

该工具可以将程序地址换为文件名、函数名和源代码行号,主要用来调试或反汇编

使用交叉编译器 gcc 命令交叉编译生成 hello 程序

guowenxue@ubuntu20:~$ arm-none-linux-gnueabihf-gcc hello.c -o hello

可以使用 file 命令查看 hello 程序的相关信息.

guowenxue@ubuntu20:~$ file hello
hello: ELF 32-bit LSB executable, ARM, EABI5 version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-armhf.so.3, for GNU/Linux 3.2.0, with debug_info, not stripped

使用交叉编译器 ar 命令工具制作静态库文件.

guowenxue@ubuntu20:~$ vim file1.c 
int func_add(int a, int b)
{
        return a+b;
} 

guowenxue@ubuntu20:~$ vim file2.c  
int func_sub(int a, int b)
{
        return a-b;
} 

guowenxue@ubuntu20:~$ arm-none-linux-gnueabihf-gcc -c file1.c file2.c
guowenxue@ubuntu20:~$ ls file*.o

guowenxue@ubuntu20:~$ arm-none-linux-gnueabihf-ar -rcs libalg.a file1.o file2.o

使用交叉编译器 readelf 命令查看 hello 程序 ELF信息.

guowenxue@ubuntu20:~$ arm-none-linux-gnueabihf-readelf -a hello

使用交叉编译器 nm 命令查看 hello 程序符号表。

guowenxue@ubuntu20:~$ arm-none-linux-gnueabihf-nm hello  

使用交叉编译器 strings 命令显示可执行程序中能打印出来的字符串,如代码中的字符串常量 “Hello, World”等.

guowenxue@ubuntu20:~$ arm-none-linux-gnueabihf-strings hello

使用交叉编译器 size 命令查看 hello 程序各个段大小,单片机裸机开发环境(如STM32CubeIDE)在编译生成可执行文件后,通常会使用该工具列出相关段信息。

guowenxue@ubuntu20:~$ arm-none-linux-gnueabihf-size hello
   text    data     bss     dec     hex filename
   1102     324       4    1430     596 hello

使用交叉编译器 objcopy 命令将 ELF 可执行文件转换成单片机Flash烧写的 binary格式.bin文件 或 摩托罗拉 .srec 格式文件。单片机裸机开发环境(如STM32CubeIDE)在编译生成ELF文件后会将其转换成 .bin文件.

guowenxue@ubuntu20:~$ arm-none-linux-gnueabihf-objcopy -O binary hello hello.bin
guowenxue@ubuntu20:~$ arm-none-linux-gnueabihf-objcopy -O srec hello hello.srec 

使用交叉编译器 objdump 命令反汇编 hello 程序

guowenxue@ubuntu20:~$ arm-none-linux-gnueabihf-objdump -D hello > hello.s

使用交叉编译器 strip 命令去掉 hello 调试信息,可以看到文件明显变小。

guowenxue@ubuntu20:~$ ls -l hello
-rwxrwxr-x 1 guowenxue guowenxue 8152 Jul 31 14:50 hello

guowenxue@ubuntu20:~$ arm-none-linux-gnueabihf-strip hello

guowenxue@ubuntu20:~$ ls -l hello                    
-rwxrwxr-x 1 guowenxue guowenxue 5524 Jul 31 14:59 hello

guowenxue@ubuntu20:~$ file hello
hello: ELF 32-bit LSB shared object, ARM, EABI5 version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-armhf.so.3, BuildID[sha1]=2874be72251c350c355f9ce98b7b5f99016b4a6a, for GNU/Linux 3.2.0, stripped

2.2 GPIO编程之LED灯设备控制

版权声明

本文档所有内容文字资料由凌云实验室郭工编著,主要用于凌云嵌入式Linux教学内部使用,版权归属作者个人所有。任何媒体、网站、或个人未经本人协议授权不得转载、链接、转帖或以其他方式复制发布/发表。已经授权的媒体、网站,在下载使用时必须注明来源,违者本人将依法追究责任。

  • Copyright (C) 2021 凌云物网智科实验室·郭工

  • Author: GuoWenxue <guowenxue@gmail.com> QQ: 281143292

wechat_pub

2.2.1 板载SysLed灯控制

在 IGKBoard-IMX6ULL 开发板上板载有一个红色的用户编程控制的Led灯,该Led灯我们在Linux系统内核里默认初始化为系统运行状态心跳指示灯,它每隔一段时间将会闪烁一次以示系统状态。我们将以该Led灯为例,讲解Linux内核自带 Led 驱动的使用。

/sys/class/leds 路径下,将会存放Linux内核里所有使能的 led 灯,下面的 sysled 就是我们添加的系统状态灯。

root@igkboard:~# ls /sys/class/leds/
mmc0::  mmc1::  sysled

sysled 是一个符号链接,指向另外一个文件夹。接下来我们看看它里面都有哪些东西。

root@igkboard:~# ls /sys/class/leds/sysled
brightness  device  invert  max_brightness  power  subsystem  trigger  uevent

通过 sysled 文件夹下的这些文件,我们就可以控制 Led 灯的工作模式和状态了,下面是几个重要的文件:

  • trigger 该文件用来设置 Led的工作模式,如 heartbeat、timer、backlight、none等;

  • max_brightness 该文件列出Led灯支持的最大亮度,普通的 GPIO 灯默认为1,如果是LCD 的背光灯这里将会显示它支持的最大亮度;

  • brightness 该文件用来设置 Led灯的亮度,普通的GPIO Led灯设置为1将点亮,0将熄灭。而对于LCD背光而言设置该值将会调整LCD的屏幕亮度;

首先我们查看 trigger 文件的内容,里面列出了当前 Led 驱动所支持的工作模式,而 [heartbeat] 则说明当前工作在 heartbeat

root@igkboard:~# cat /sys/class/leds/sysled/trigger 
none rc-feedback kbd-scrolllock kbd-numlock kbd-capslock kbd-kanalock kbd-shiftlock kbd-altgrlock kbd-ctrllock kbd-altlock kbd-shiftllock kbd-shiftrlock kbd-ctrlllock kbd-ctrlrlock timer oneshot [heartbeat] backlight gpio default-on mmc1 mmc0

往该文件里写入 “timer” 则会切换到定时器模式,此时Led将每隔固定时间亮灭一下。

root@igkboard:~# echo "timer" > /sys/class/leds/sysled/trigger 

如果我们想手动控制该 Led 灯的工作状态,则可以将其设置为 “none”。

root@igkboard:~# echo "none" > /sys/class/leds/sysled/trigger 

然后,我们可以通过修改 brightness 文件来控制 Led 灯的亮灭,其中写 1 点亮Led灯,写 0 熄灭Led灯。

root@igkboard:~# echo 1 > /sys/class/leds/sysled/brightness 
root@igkboard:~# echo 0 > /sys/class/leds/sysled/brightness

对于普通的 GPIO Led灯,它只有亮灭两种状态,所以 max_brightness 文件里的值为1。如果为 PWM 控制的 Backlight Led则可以支持多级亮度调节。

root@igkboard:~# cat /sys/class/leds/sysled/max_brightness 
1

上面是Linux内核自带的 Led 灯驱动使用,接下来我们将以 RGB 三色灯为例讲解在用户空间如何使用GPIO控制Led灯、继电器等设备。

2.2.2 RGB三色灯命令控制

2.2.2.1 RGB三色灯连接

我们知道,大自然色由Red,Green,Blue三原色(或称三基色)组成,在特定的颜色和亮度配比下可以发出可见光谱波段的任意颜色,如RGB三个颜色全部点亮时则会表现出白色。下面是我在 淘宝上 购买的一个 共阴极 RGB三色Led灯。

rgb_led

需要注意的是,通常RGB三色Led灯硬件设计上有两种接法,如下图所示:

rgb_sch

  • 共阳极(通常PCB上标识V或VCC) 将所有发光二极管的阳极(VCC)接到一起,这样我们通过编程控制R、G、B引脚 输出低电平时 相应的 Led灯就亮了 ;反之如果控制 输出高电平 则相应的 Led灯就灭了

  • 共阴极(通常PCB上标识G或GND) 将所有发光二极管的阴极(GND)接到一起,这样我们通过编程控制R、G、B引脚 输出高电平时 相应的 Led灯就亮了 ;反之如果控制 输出低电平 则相应的 Led灯就灭了

IGKBoard开发板上提供树莓派兼容的40Pin 扩展引脚,这些引脚默认作为 GPIO 功能使用。这里我们所使用的三色Led灯为共阴极,为方便取电我们就连到了开发板的 #33、#35、#37、#39(GND) 脚上了,如果大家购买的三色Led灯为共阳极的话,则可以使用 #1(VCC 3.3V)、#3、#5、#7 引脚。

40pin_hat

下面是我们的共阴极 RGB 三色Led灯的物理连接示意图。

igkboard_rgbled

从上面的连接示意图我们可以看出:

  • RGB三色灯 R(红灯) 连接到了 开发板40Pin 33#脚上;

  • RGB三色灯 G(绿灯) 连接到了 开发板40Pin 35#脚上;

  • RGB三色灯 B(蓝灯) 连接到了 开发板40Pin 37#脚上;

  • RGB三色灯 G(Gnd) 连接到了 开发板40Pin 39#脚上;

接下来我们将以如何控制这个三色Led灯为例,来讲解Linux系统下的GPIO 操作命令及C语言编程接口API。

2.2.2.2 sysfs 方式控制

在Linux系统中,sysfs是一个基于 RAM(内存) 的虚拟文件系统(伪文件系统),用于提供内核数据结构的访问和管理。它通常挂载在/sys目录下。sysfs允许用户空间程序与内核之间进行通信,并提供了一种机制来查询和配置内核的状态信息。主要特点包括:

  • 虚拟文件系统: sysfs是一个虚拟文件系统,它不会存储在硬盘上,而是由内核动态生成的,以提供对内核数据结构的访问。

  • 设备信息: sysfs提供了有关系统中已安装的设备的详细信息,包括PCI设备、USB设备、串口设备、GPIO等。

  • 驱动信息: 对于加载的驱动程序,sysfs提供了相关信息,如驱动程序的状态、参数和特性。

  • 总线信息: sysfs提供了有关系统中总线的信息,如PCI总线、USB总线等。

  • 内核参数配置: 通过sysfs,用户可以配置一些内核参数,例如调整内核模块的行为或设置设备的属性。

  • 事件通知: 有些驱动程序会在sysfs中创建文件,用于通知用户空间程序发生的事件,从而实现设备状态的实时监控。

通过sysfs,用户可以直接通过文件系统的接口来与内核进行交互,而不需要直接操作内核数据结构。这种抽象层提供了一种方便而统一的方式来管理和配置系统硬件和内核参数,使得Linux系统更加灵活和易于管理。

早期的Linux内核默认支持 sysfs 文件系统来控制 GPIO,现在最新的默认Linux内核不支持了,而是由 libgpiod库 来替代。如果想要使能该接口的话,则需要在Linux内核编译前的 make menuconfig 中使能下面选项:

guowenxue@ubuntu20:~/igkboard-imx6ull/kernel/linux-imx$ make menuconfig

General setup  --->
	[*] Configure standard kernel features (expert users)  --->

Device Drivers  ---> 
	-*- GPIO Support  --->
		[*]   /sys/class/gpio/... (sysfs interface)
		-*-   Character device (/dev/gpiochipN) support

我们的 IGKBoard 开发板默认支持sysfs 文件系统来控制 GPIO,这点可以由查看 /sys/class/gpio 文件夹是否存在来判断,如果存在则说明系统支持。

root@igkboard:~# ls /sys/class/gpio/
export  gpiochip0  gpiochip128  gpiochip32  gpiochip64  gpiochip96  unexport

下面我们简单介绍/sys/class/gpio中文件的作用

  • /sys/class/gpio/export 用于通知Linux内核导出需要控制的GPIO引脚编号;

  • /sys/class/gpio/unexport 用于通知Linux内核取消导出的GPIO;

  • /sys/class/gpio/gpiochipX 目录保存系统中GPIO寄存器的信息,包括每个寄存器控制引脚的起始编号base,寄存器名称,引脚总数 导出一个引脚的操作步骤

如果我们想要使用 sysfs 文件系统来控制相应的 GPIO 引脚输出高低电平,从而控制 RGB 三色Led灯的亮灭,那我们首先需要知道这三个 Led 灯所连接的 GPIO口引脚编号。在 IGKBoard开发板上,我们可以使用 pinctrl -v 命令可以查看各引脚的功能定义。

pinctrl_40pin

前面我们将RGB三色灯的红、绿、蓝分别连到了物理引脚 #33、#35、#37 上,这样他们分别对应的 GPIO口为 GPIO1_26、GPOI5_IO01、GPIO5_IO08。又因为该 Led灯为共阴极模式,这样如果我们将相应的GPIO口设置为输出模式并输出高电平,则相应的Led灯就亮了;而如果设置为低电平,则相应的灯就灭了。

要想控制这几个Led灯,则首先需要将相应的Led通过 /sys/class/gpio/export 文件导出,这时我们需要根据该引脚计算出其编号,该计算公式为:

假设需要导出的gpio是GPIO0X_IOY,计算其编号为 NUM = (X - 1) * 32 + Y, 示例 :
GPIO01_IO23 = (1-1)*32 + 23 = 23
GPIO05_IO01 = (5-1)*32 + 1 = 129
GPIO05_IO08 = (5-1)*32 + 8 = 136

接下来我们使用 echo 命令将相应的 GPIO 引脚导出。

root@igkboard:~# sh -c 'echo 23 > /sys/class/gpio/export'  
root@igkboard:~# sh -c 'echo 129 > /sys/class/gpio/export'  
root@igkboard:~# sh -c 'echo 136 > /sys/class/gpio/export'  

这时候,我们可以看到在 /sys/class/gpio/ 路径下,导出了这三个 GPIO 引脚。

root@igkboard:~# ls /sys/class/gpio/
export  gpio129  gpio136  gpio23  gpiochip0  gpiochip128  gpiochip32  gpiochip64  gpiochip96  unexport

接下来以 GPIO01_IO23 为例查看文件夹,下面将讲解三个常用属性接口 :

root@igkboard:~# ls /sys/class/gpio/gpio23
active_low  device  direction  edge  power  subsystem  uevent  value
  • direction:gpio的输入输出属性,可以为 inout。按键应该设置为 in, 而 Led灯则为 out。

  • active_low:gpio的有效电平为低使能属性,可以为1或0(一般为0)。active_low为1(真)时,低电平为有效电平,value值为1则GPIO电平为低电平,value值为0则GPIO电平为高电平;active_low为0(假)时,高电平为有效电平,value值为1则GPIO电平为高电平,value值为0则GPIO电平为低电平;;

  • value:gpio的电平值,实际电平高低和有效电平属性相关,可以为1或0。

接下来我们设置三个Led灯的相应 GPIO 引脚为输出模式。

root@igkboard:~# sh -c 'echo out > /sys/class/gpio/gpio23/direction'
root@igkboard:~# sh -c 'echo out > /sys/class/gpio/gpio129/direction'  
root@igkboard:~# sh -c 'echo out > /sys/class/gpio/gpio136/direction'  

查看三个 Led 灯的默认有效电平属性,其值为0说明有效电平为高电平。这也就意味着如果我们往 value 文件里写 1 就点亮 Led灯(高电平有效),而写 0 则 Led 灯熄灭。

root@igkboard:~# cat /sys/class/gpio/gpio23/active_low 
0
root@igkboard:~# cat /sys/class/gpio/gpio129/active_low   
0
root@igkboard:~# cat /sys/class/gpio/gpio136/active_low    
0

这样我们使用下面三条命令就可以点亮/熄灭相应的 Led 灯了。当三个Led灯都点亮时它就会发出白色光,大家也可以试试其他的组合方式,观察其对应的颜色变化。

root@igkboard:~# sh -c 'echo 1 > /sys/class/gpio/gpio23/value'
root@igkboard:~# sh -c 'echo 0 > /sys/class/gpio/gpio23/value'

root@igkboard:~# sh -c 'echo 1 > /sys/class/gpio/gpio129/value'  
root@igkboard:~# sh -c 'echo 0 > /sys/class/gpio/gpio129/value'  

root@igkboard:~# sh -c 'echo 1 > /sys/class/gpio/gpio136/value'  
root@igkboard:~# sh -c 'echo 0 > /sys/class/gpio/gpio136/value'  

用完之后我们使用下面命令取消相应 GPIO 引脚的导出。

root@igkboard:~# sh -c 'echo 23 > /sys/class/gpio/unexport'  
root@igkboard:~# sh -c 'echo 129 > /sys/class/gpio/unexport'  
root@igkboard:~# sh -c 'echo 136 > /sys/class/gpio/unexport'  

root@igkboard:~# ls /sys/class/gpio/
export  gpiochip0  gpiochip128  gpiochip32  gpiochip64  gpiochip96  unexport

为方便 IGKBoard 上的普通 GPIO口使用,在系统中我们编写了 pinctrl 系统命令脚本,他可以完成 IGKBoard 开发板上的 GPIO 输出控制和输入读取操作,其用法如下:

root@igkboard:~# pinctrl -h    
Show pinmap Usage: /usr/sbin/pinctrl [-v]
Output set  Usage: /usr/sbin/pinctrl GPIO01_IO11 [1/0]
Input read  Usage: /usr/sbin/pinctrl [-i] GPIO01_IO11
Unexport    Usage: /usr/sbin/pinctrl [-u] GPIO01_IO11

这样,我们可以直接使用该命令来控制 Led 灯的亮灭。

root@igkboard:~# pinctrl GPIO1_IO23 1
root@igkboard:~# pinctrl GPIO1_IO23 0

root@igkboard:~# pinctrl GPIO05_IO01 1
root@igkboard:~# pinctrl GPIO05_IO01 0

root@igkboard:~# pinctrl GPIO05_IO08 1
root@igkboard:~# pinctrl GPIO05_IO08 0

如果想要获取某个引脚的输入状态(如按键或红外传感器灯),则可以使用下面命令来读取相应引脚电平状态:

root@igkboard:~# pinctrl -i GPIO05_IO01
0

用完后,取消这些引脚的导出。

root@igkboard:~# pinctrl -u GPIO1_IO23
root@igkboard:~# pinctrl -u GPIO5_IO01
root@igkboard:~# pinctrl -u GPIO5_IO08

2.2.2.3 gpiod方式控制

从 linux kernel 4.8开始加入了libgpiod的支持;而原有基于sysfs的访问方式,将被逐渐放弃。当前内核中的GPIO操作架构如下:

img

gpiodlibgpiod 库提供的一个用于与Linux GPIO子系统交互的命令行工具集。它允许用户查询、设置和监视系统中的GPIO引脚状态,以及执行与GPIO相关的其他操作。以下是一些gpiod命令的常见用法:

查询GPIO Chip信息:

gpiodetect

该命令会列出系统中所有可用的GPIO chip信息。

下面是在 IGKBoard 开发板上执行的信息。

root@igkboard:~# gpiodetect 
gpiochip0 [209c000.gpio] (32 lines)  # 对应 GPIO1_IO0~GPIO1_IO31
gpiochip1 [20a0000.gpio] (32 lines)  # 对应 GPIO2_IO0~GPIO2_IO31
gpiochip2 [20a4000.gpio] (32 lines)  # 对应 GPIO3_IO0~GPIO3_IO31
gpiochip3 [20a8000.gpio] (32 lines)  # 对应 GPIO4_IO0~GPIO4_IO31
gpiochip4 [20ac000.gpio] (32 lines)  # 对应 GPIO5_IO0~GPIO5_IO31

/dev 路径下对应存在这些 chip 的设备文件。

root@igkboard:~# ls /dev/gpiochip*
/dev/gpiochip0  /dev/gpiochip1  /dev/gpiochip2  /dev/gpiochip3  /dev/gpiochip4

查询GPIO口的使用信息:

gpioinfo -c <chip>

列出指定chip的所有引脚信息,如果不指定则列出所有 GPIO 口的信息。

root@igkboard:~# gpioinfo -c 0  
gpiochip0 - 32 lines:
        line   0:       unnamed                 input
        line   1:       unnamed                 input
        line   2:       unnamed                 input
        line   3:       unnamed                 input
        line   4:       unnamed                 input
        line   5:       unnamed                 input
        line   6:       unnamed                 input
        line   7:       unnamed                 input
        line   8:       unnamed                 input
        line   9:       unnamed                 output consumer=regulator-sd1-vmmc
        line  10:       unnamed                 input
        line  11:       unnamed                 input
        line  12:       unnamed                 input
        line  13:       unnamed                 input
        line  14:       unnamed                 input
        line  15:       unnamed                 input
        line  16:       unnamed                 input
        line  17:       unnamed                 input
        line  18:       unnamed                 output drive=open-drain consumer=w1
        line  19:       unnamed                 input active-low consumer=cd
        line  20:       unnamed                 input
        line  21:       unnamed                 input
        line  22:       unnamed                 input
        line  23:       unnamed                 input
        line  24:       unnamed                 input
        line  25:       unnamed                 input
        line  26:       unnamed                 input
        line  27:       unnamed                 input
        line  28:       unnamed                 input
        line  29:       unnamed                 input
        line  30:       unnamed                 input
        line  31:       unnamed                 input

设置GPIO引脚输出:

gpioset -c <chip> <line>

这个命令用于设置指定GPIO芯片(chip)上的特定GPIO线(line)的状态,将其值设置为指定的值(0或1),从而输出高、第电平。上面为当前最新版本 gpiod 使用方法,它需要使用 -c 选项来指定相应的 chip,而早期版本(v1.x)则不需要,其用法为 gpioget <chip> <line>

root@igkboard:~# gpioset --version
gpioset (libgpiod) v2.0
Copyright (C) 2017-2023 Bartosz Golaszewski
License: GPL-2.0-or-later
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.

该命令的具体其他使用方法其查看其帮助信息。

root@igkboard:~# gpioset --help
Usage: gpioset [OPTIONS] <line=value>...

Set values of GPIO lines.

Lines are specified by name, or optionally by offset if the chip option
is provided.
Values may be '1' or '0', or equivalently 'active'/'inactive' or 'on'/'off'.

The line output state is maintained until the process exits, but after that
is not guaranteed.

Options:
      --banner          display a banner on successful startup
  -b, --bias <bias>     specify the line bias
                        Possible values: 'pull-down', 'pull-up', 'disabled'.
                        (default is to leave bias unchanged)
      --by-name         treat lines as names even if they would parse as an offset
  -c, --chip <chip>     restrict scope to a particular chip
  -C, --consumer <name> consumer name applied to requested lines (default is 'gpioset')
  -d, --drive <drive>   specify the line drive mode
                        Possible values: 'push-pull', 'open-drain', 'open-source'.
                        (default is 'push-pull')
  -h, --help            display this help and exit
  -i, --interactive     set the lines then wait for additional set commands
                        Use the 'help' command at the interactive prompt to get help
                        for the supported commands.
  -l, --active-low      treat the line as active low
  -p, --hold-period <period>
                        the minimum time period to hold lines at the requested values
  -s, --strict          abort if requested line names are not unique
  -t, --toggle <period>[,period]...
                        toggle the line(s) after the specified period(s)
                        If the last period is non-zero then the sequence repeats.
      --unquoted        don't quote line names
  -v, --version         output version information and exit
  -z, --daemonize       set values then detach from the controlling terminal

Chips:
    A GPIO chip may be identified by number, name, or path.
    e.g. '0', 'gpiochip0', and '/dev/gpiochip0' all refer to the same chip.

Periods:
    Periods are taken as milliseconds unless units are specified. e.g. 10us.
    Supported units are 's', 'ms', and 'us'.

*Note*
    The state of a GPIO line controlled over the character device reverts to default
    when the last process referencing the file descriptor representing the device file exits.
    This means that it's wrong to run gpioset, have it exit and expect the line to continue
    being driven high or low. It may happen if given pin is floating but it must be interpreted
    as undefined behavior.

使用下面命令可以点亮开发板上的Led灯,如果一个 chip 上有多个 GPIO line控制,也可以同时控制。

root@igkboard:~# gpioset -c 0 23=1
root@igkboard:~# gpioset -c 4 1=1 8=1 

使用下面命令则熄灭相应的 Led 灯。

root@igkboard:~# gpioset -c 0 23=0
root@igkboard:~# gpioset -c 4 1=0 8=0

获取GPIO引脚输入:

gpioset -c <chip> <line>

这个命令用于查询指定GPIO芯片(chip)上的特定GPIO线(line)的状态,如用来获取按键、人体红外的状态等。当前最新版本 gpiod 需要使用 -c 选项来指定相应的 chip,其用法为 gpioget -c <chip> <line> ; 而早期版本(v1.x)则不需要,其用法为 gpioget <chip> <line>

使用下面命令可以获取 GPIO1_IO23 引脚的状态。

root@igkboard:~# gpioget -c 0 23
"23"=inactive

2.2.3 RGB三色灯编程控制

2.2.3.1 libgpiod库简介

libgpiod 是一个用于在Linux系统上访问通用输入/输出(GPIO)设备的C库。它提供了一个用户空间API,允许开发者以编程方式控制和管理系统上的GPIO引脚。libgpiod库 通常与 gpiod 命令行工具一起使用,用于开发GPIO相关的应用程序。

以下是 libgpiod 的一些主要特点和功能:

  • 简单易用的API:libgpiod提供了一个简单易用的API,使开发者能够轻松地在应用程序中对GPIO进行配置、读取和写入操作。

  • 抽象底层细节:libgpiod抽象了底层GPIO硬件的细节,使开发者无需关注底层硬件的具体实现细节,从而更容易编写跨平台的GPIO应用程序。

  • 支持事件监听:libgpiod允许应用程序注册回调函数,以便在GPIO状态发生变化时接收通知。这使得应用程序能够实时响应GPIO状态的变化。

  • 支持多种GPIO控制器:libgpiod支持多种类型的GPIO控制器,包括基于sysfs的GPIO、MMIO(内存映射输入/输出)和chardev(字符设备)。

  • 支持GPIO组:libgpiod允许开发者对GPIO进行分组,并以组的形式进行管理和控制,从而提高了代码的可维护性和可重用性。

  • 兼容性:libgpiod兼容各种不同的GPIO硬件和系统,因此可以在各种不同的嵌入式系统和单板计算机上使用。

总的来说,libgpiod 为开发者提供了一个方便、灵活且功能丰富的工具,使他们能够轻松地开发基于GPIO的应用程序,并在Linux系统上进行GPIO设备的管理和控制。

IGKBoard开发板的linux系统里默认支持 libgpiod 库,其存放在 /usr/lib 路径下。。

root@igkboard:~# ls /usr/lib/libgpiod*.so*
/usr/lib/libgpiod.so  /usr/lib/libgpiod.so.3  /usr/lib/libgpiod.so.3.0.0  /usr/lib/libgpiodcxx.so  /usr/lib/libgpiodcxx.so.2  /usr/lib/libgpiodcxx.so.2.0.0

使用任何一个 gpiod 命令都可以查询开发板上 libgpiod 的版本信息。

root@igkboard:~# gpioset --version 
gpioset (libgpiod) v2.0
Copyright (C) 2017-2023 Bartosz Golaszewski
License: GPL-2.0-or-later
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.

2.2.3.2 libgpiod交叉编译

虽然开发板上自带有 libgpiod 的动态库,但我们在做 X86 服务器上来开发C程序时,在编译和链接过程中需要其相应的头文件和动态库文件,这样我们就需要在服务器上交叉编译该动态库。

需要注意的是,我们交叉编译的源码版本需要开发板上的保持一致,至少大版本要保持一致,否则在后面的编程时API变化会导致程序不能正常运行。这里我们就从 libgpiod 的官方站点上 下载相应的版本。

guowenxue@ubuntu20:~$ cd igkboard/apps/
guowenxue@ubuntu20:~/igkboard/apps$ mkdir libgpiod && cd libgpiod
guowenxue@ubuntu20:~/igkboard/apps/libgpiod$ wget https://git.kernel.org/pub/scm/libs/libgpiod/libgpiod.git/snapshot/libgpiod-2.0.tar.gz

下载完成后,解压缩并进入源码查看里面的相关文件。

guowenxue@ubuntu20:~/igkboard/apps/libgpiod$ tar -xzf libgpiod-2.0.tar.gz 
guowenxue@ubuntu20:~/igkboard/apps/libgpiod$ cd libgpiod-2.0/
guowenxue@ubuntu20:~/igkboard/apps/libgpiod/libgpiod-2.0$ ls
autogen.sh  bindings  configure.ac  COPYING  Doxyfile.in  include  lib  LICENSES  Makefile.am  man  NEWS  README  tests  TODO  tools

从上面我们可以看到,该源码下并没有 configureMakefile 文件,但有 autogen.sh 脚本文件。这时首先云需要运行该脚本文件,用来生成 configure 文件。

guowenxue@ubuntu20:~/igkboard/apps/libgpiod/libgpiod-2.0$ ./autogen.sh 
autoreconf: Entering directory `.'
autoreconf: configure.ac: not using Gettext
autoreconf: running: aclocal --force -I m4
aclocal: warning: couldn't open directory 'm4': No such file or directory
autoreconf: configure.ac: tracing
autoreconf: configure.ac: creating directory autostuff
autoreconf: running: libtoolize --copy --force
libtoolize: putting auxiliary files in AC_CONFIG_AUX_DIR, 'autostuff'.
... ...

这时再看文件夹下,就会生成 configureMakefile 文件了。

guowenxue@ubuntu20:~/igkboard/apps/libgpiod/libgpiod-2.0$ ls
aclocal.m4  autom4te.cache  bindings  config.h.in  config.status  configure.ac  Doxyfile.in  lib      LICENSES  Makefile     Makefile.in  NEWS    stamp-h1  TODO
autogen.sh  autostuff       config.h  config.log   configure      COPYING       include      libtool  m4        Makefile.am  man          README  tests     tools

一般开源源码下都会有一个 configure 的 Shell脚本,该脚本用来配置交叉编译器并自动生成 Makefile 文件。通常,在移植开源库时都会使用 ./configure --help 命令查看一下,当前源码所支持的编译选项。

guowenxue@ubuntu20:~/igkboard/apps/libgpiod/libgpiod-2.0$ ./configure --help
`configure' configures libgpiod 2.0 to adapt to many kinds of systems.

Usage: ./configure [OPTION]... [VAR=VALUE]...

To assign environment variables (e.g., CC, CFLAGS...), specify them as
VAR=VALUE.  See below for descriptions of some of the useful variables.

Defaults for the options are specified in brackets.

下面是一些绝大部分开源代码都支持的通用的 configure 选项:

  • –prefix 该选项用来指定编译完成后,make install 安装的目标文件;

  • –build 该选项用来指定本地编译的服务器,一般指定为 --build=i686-pc-linux

  • –host 该选项用来指定源码编译出来运行的目标主机,一般指定为 --host=arm-linux

  • –enable-xxx ,该选项用来使能某个特性,如 --enable-static 用来指定编译要生成静态库;

  • –disable-xxx,该选项用来关闭某个特性,如 --disable-static 用来指定不要编译生成静态库;

  • –with-xxx ,该选项用来指定需要包含某个软件包;

  • –without-xxx ,该选项用来指定不需要包含某个软件包;

另外,一般下面的一些环境变量将会影响开源代码的编译,如我们想要交叉编译给 ARM Linux开发板运行,则必须修改 CC 环境变量。

  • CC 该选项用来指定C程序交叉编译器;

  • CXX 该选项用来指定C++程序交叉编译器;

  • CFLAGS 该选项用来指定C程序额外的编译选项,如我们在编译某个程序源码时还依赖另外一个第三方库,则可以通过该选项来指定第三方库的头文件路径。如 CFLAGS += -I path/to/include

  • CXXFLAGS 该选项用来指定C++程序额外的编译选项;

  • LDFLAGS 该选项用来指定额外的链接选项,如我们在编译某个程序源码时还依赖另外一个第三方库,则可以通过该选项来指定第三方库的库文件路径。如 LDFLAGS += -L path/to/lib

  • LIBS 该选项也可以用来指定额外需要的第三方库。

接下来我们开始 libgpiod 的移植过程,首先要导出我们的交叉编译器链,所有的开源源码移植都依赖这个过程。

guowenxue@ubuntu20:~/igkboard/apps/libgpiod/libgpiod-2.0$ export CROSS_COMPILE=arm-linux-gnueabihf-
guowenxue@ubuntu20:~/igkboard/apps/libgpiod/libgpiod-2.0$ export CC=${CROSS_COMPILE}gcc
guowenxue@ubuntu20:~/igkboard/apps/libgpiod/libgpiod-2.0$ export CXX=${CROSS_COMPILE}g++
guowenxue@ubuntu20:~/igkboard/apps/libgpiod/libgpiod-2.0$ export AS=${CROSS_COMPILE}as
guowenxue@ubuntu20:~/igkboard/apps/libgpiod/libgpiod-2.0$ export AR=${CROSS_COMPILE}ar
guowenxue@ubuntu20:~/igkboard/apps/libgpiod/libgpiod-2.0$ export LD=${CROSS_COMPILE}ld
guowenxue@ubuntu20:~/igkboard/apps/libgpiod/libgpiod-2.0$ export NM=${CROSS_COMPILE}nm
guowenxue@ubuntu20:~/igkboard/apps/libgpiod/libgpiod-2.0$ export RANLIB=${CROSS_COMPILE}ranlib
guowenxue@ubuntu20:~/igkboard/apps/libgpiod/libgpiod-2.0$ export OBJDUMP=${CROSS_COMPILE}objdump
guowenxue@ubuntu20:~/igkboard/apps/libgpiod/libgpiod-2.0$ export STRIP=${CROSS_COMPILE}strip

另外,防止其他的一些默认编译选项、链接选项会影响当前库的编译,我们可能需要清除 CFLAGSLDFLAGS

guowenxue@ubuntu20:~/igkboard/apps/libgpiod/libgpiod-2.0$ unset CFLAGS
guowenxue@ubuntu20:~/igkboard/apps/libgpiod/libgpiod-2.0$ unset LDFLAGS

接下来我们开始对源码进行 configure 配置,结果后面提示失败。

guowenxue@ubuntu20:~/igkboard/apps/libgpiod/libgpiod-2.0$ ./configure --checking for a checking for a BSD-compatible install... /usr/bin/install -c
checking whether build environment is sane... yes
checking for arm-linux-strip... arm-linux-gnueabihf-strip
checking for a thread-safe mkdir -p... /usr/bin/mkdir -p
checking for gawk... gawk
checking whether make sets $(MAKE)... yes
checking whether make supports nested variables... yes
checking whether make supports nested variables... (cached) yes
checking whether make supports the include directive... yes (GNU style)
checking for arm-linux-gcc... arm-linux-gnueabihf-gcc
checking whether the C compiler works... yes
checking for C compiler default output file name... a.out
checking for suffix of executables... 
checking whether we are cross compiling... yes
checking for suffix of object files... o
checking whether we are using the GNU C compiler... yes
checking whether arm-linux-gnueabihf-gcc accepts -g... yes
checking for arm-linux-gnueabihf-gcc option to accept ISO C89... none needed
checking whether arm-linux-gnueabihf-gcc understands -c and -o together... yes
checking dependency style of arm-linux-gnueabihf-gcc... gcc3
checking for arm-linux-ar... arm-linux-gnueabihf-ar
checking the archiver (arm-linux-gnueabihf-ar) interface... ar
checking for arm-linux-gcc... (cached) arm-linux-gnueabihf-gcc
... ...
config.status: creating bindings/rust/gpiosim-sys/Makefile
config.status: creating man/Makefile
config.status: creating config.h
config.status: executing depfiles commands
config.status: executing libtool commands
  • –prefix=`pwd`/../install 该选项用来指定编译完成后 make install 安装目标文件到源码上一级目录下,注意这里必须使用绝对路径,所以我们使用 pwd 命令来获取当前路径的绝对路径;

  • –build=i686-pc-linux 该选项用来指定当前的编译服务器 X86_64位Linux;

  • –host=arm-linux 该选项用来指定目标运行的主机为 ARM Linux;

  • –enable-static 该选项用来指定,需要生成 libgpiod 的静态库文件;

  • –enable-tools 该选项为 libgpiod 库特有的编译选项,用来指定需要编译生成 gpiod 相关命令,否则不会生成;

configure 成功之后,接下来我们尝试 make 编译源码,结果失败。

guowenxue@ubuntu20:~/igkboard/apps/libgpiod/libgpiod-2.0$ make 
make  all-recursive
make[1]: Entering directory '/home/guowenxue/igkboard/apps/libgpiod/libgpiod-2.0'
Making all in include
... ...
  CCLD     libtools-common.la
  CCLD     gpiodetect
/usr/lib/gcc-cross/arm-linux-gnueabihf/9/../../../../arm-linux-gnueabihf/bin/ld: ./.libs/libtools-common.a(tools-common.o): in function `chip_paths':
/home/guowenxue/igkboard/apps/libgpiod/libgpiod-2.0/tools/tools-common.c:438: undefined reference to `rpl_malloc'
/usr/lib/gcc-cross/arm-linux-gnueabihf/9/../../../../arm-linux-gnueabihf/bin/ld: ./.libs/libtools-common.a(tools-common.o): in function `resolver_init':
/home/guowenxue/igkboard/apps/libgpiod/libgpiod-2.0/tools/tools-common.c:562: undefined reference to `rpl_malloc'
collect2: error: ld returned 1 exit status
make[2]: *** [Makefile:527: gpiodetect] Error 1
make[2]: Leaving directory '/home/guowenxue/igkboard/apps/libgpiod/libgpiod-2.0/tools'
make[1]: *** [Makefile:463: all-recursive] Error 1
make[1]: Leaving directory '/home/guowenxue/igkboard/apps/libgpiod/libgpiod-2.0'
make: *** [Makefile:393: all] Error 2

上面是这个错误是因为 rpl_malloc 未被定义,它 是 GNU libc 提供的一个函数,属于“可替换的内存分配”功能。 打开configure,发现里面有 #define malloc rpl_malloc 一行。分析 configure 脚本相关的代码,原来是ac_cv_func_malloc_0_nonnull 引起的,OK我们不让它检查了,产生一个cache文件 arm- linux.cache,欺骗configure:

guowenxue@ubuntu20:~/igkboard/apps/libgpiod/libgpiod-2.0$ echo "ac_cv_func_malloc_0_nonnull=yes" > arm-linux.cache

guowenxue@ubuntu20:~/igkboard/apps/libgpiod/libgpiod-2.0$ ./configure --prefix=`pwd`/../install --build=i686-pc-linux --host=arm-linux --enable-static --enable-tools --cache-file=arm-linux.cache

重新配置后再次 make 编译源码成功。

guowenxue@ubuntu20:~/igkboard/apps/libgpiod/libgpiod-2.0$ make 

接下来再 make install 安装相应的 gpiod 命令、动态库、头文件等到指定的 prefix 路径下。

guowenxue@ubuntu20:~/igkboard/apps/libgpiod/libgpiod-2.0$ make install

现在我们可以看到:

guowenxue@ubuntu20:~/igkboard/apps/libgpiod/libgpiod-2.0$ tree ../install/
../install/
├── bin
│   ├── gpiodetect
│   ├── gpioget
│   ├── gpioinfo
│   ├── gpiomon
│   ├── gpionotify
│   └── gpioset
├── include
│   └── gpiod.h
└── lib
    ├── libgpiod.a
    ├── libgpiod.la
    ├── libgpiod.so -> libgpiod.so.3.0.0
    ├── libgpiod.so.3 -> libgpiod.so.3.0.0
    ├── libgpiod.so.3.0.0
    └── pkgconfig
        └── libgpiod.pc

4 directories, 13 files
  • 可执行文件 将会安装到 –prefix 选项指定路径的 bin 文件夹下;

  • 头文件 将会安装到 –prefix 选项指定路径的 include 文件夹下;

  • 库文件 将会安装到 –prefix 选项指定路径的 lib 文件夹下;

2.2.3.3 libgpiod编程API

要想用 libgpiod 库编程控制 RGB三色灯的亮灭,则需要学习了解 libgpiod 的编程API函数。网上绝大部分的博客或示例代码都是基于 libgpiod v1.0 版本的,而 libgpiod v2.0 相比 v1.0 版本编程接口API函数有了非常大的变化,这样直接参考别人的代码就不能工作。

这时我们需要查阅 libgpiod官方最新的API文档说明,同时还要查看学习libgpiod源码库里提供的示例代码,如tools文件夹下的 gpioset.c 源文件。

guowenxue@ubuntu20:~/igkboard/apps/libgpiod/libgpiod-2.0$ ls tools/*.c
tools/gpiodetect.c  tools/gpioget.c  tools/gpioinfo.c  tools/gpiomon.c  tools/gpionotify.c  tools/gpioset.c  tools/tools-common.c

网上关于 libgpiod v1.0 版本的 API 函数说明文档和示例代码比较多,这里就不作过多介绍,接下来主要介绍libgiod v2.0 的API函数。

  • 打开/关闭所需要的gpio芯片

struct gpiod_chip *gpiod_chip_open(const char *path);
void gpiod_chip_close(struct gpiod_chip *chip);

功能描述:根据gpiochip 设备路径打开需要的chip;

参数解析:path:要打开的 gpio chip 的设备路径(/dev/gpiochipX

返回值: 成功返回GPIO 芯片句柄,失败则返回 NULL 。

  • 申请/释放所需要的gpio口

struct gpiod_line_request * gpiod_chip_request_lines(struct gpiod_chip *chip, struct gpiod_request_config *req_cfg, struct gpiod_line_config *line_cfg);

void gpiod_line_request_release(struct gpiod_line_request *request);

功能描述:获取GPIO 口句柄

参数解析:chip: GPIO 芯片句柄;req_cfg: GPIO request的配置,可为空; line_cfg: GPIO line的配置

关于 req_cfg 和 line_cfg 的使用请参考下面 的 init_led() 函数

返回值:成功返回GPIO 口句柄,失败则返回 NULL。

  • 设置GPIO输出电平

int gpiod_line_request_set_value(struct gpiod_line_request *request, unsigned int offset, enum gpiod_line_value value)

功能描述:设置相应GPIO口的逻辑电平

参数解析: request: GPIO 口句柄;offset: GPIO line编号;value: 设置的逻辑电平值;

返回值:成功返回0,失败则返回<0 。

2.2.3.4 RGB三色灯编程

接下来,我们讲开始编写基于 libgpiod v2.0 库的 RGB 三色Led灯控制代码。首先切换工作目录到前面创建的 IGKBoard 应用接口编程工作目录下:

guowenxue@ubuntu20:~/igkboard/apps/libgpiod$ cd ~/igkboard/apps/

编写控制RGB三色灯的源代码 leds.c 如下。

guowenxue@ubuntu20:~/igkboard/apps$ vim leds.c 
/*********************************************************************************
 *      Copyright:  (C) 2024 LingYun IoT System Studio
 *                  All rights reserved.
 *
 *       Filename:  led.c
 *    Description:  This file is used to control RGB 3-colors LED
 *
 *
 * Pin connection:
 *               RGB Led Module           IGKBoard
 *                   R        <----->      #Pin33
 *                   G        <----->      #Pin35
 *                   B        <----->      #Pin37
 *                  GND       <----->      GND
 *
 ********************************************************************************/

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <dirent.h>
#include <string.h>
#include <time.h>
#include <errno.h>
#include <signal.h>

#include <gpiod.h>

#define DELAY     300

#define ON        1
#define OFF       0

/* Three LEDs number */
enum
{
    LED_R = 0,
    LED_G,
    LED_B,
    LEDCNT,
};

enum
{
    ACTIVE_HIGH, /* High level will turn led on */
    ACTIVE_LOW,  /* Low level will turn led on */
};

/* Three LEDs hardware information */
typedef struct led_s
{
    const char               *name;      /* RGB 3-color LED name  */
    int                       chip_num;  /* RGB 3-color LED connect chip */
    int                       gpio_num;  /* RGB 3-color LED connect line */
    int                       active;    /* RGB 3-color LED active level */
    struct gpiod_line_request *request;  /* libgpiod gpio request handler */
} led_t;

static led_t leds_info[LEDCNT] =
{
    {"red",   0, 23, ACTIVE_HIGH, NULL }, /* GPIO1_IO23 on chip0 line 23, active high */
    {"green", 4, 1,  ACTIVE_HIGH, NULL }, /* GPIO5_IO01 on chip4 line 1, active high */
    {"blue",  4, 8,  ACTIVE_HIGH, NULL }, /* GPIO5_IO08 on chip4 line 8, active high */
};

/* Three LEDs API context */
typedef struct leds_s
{
    led_t               *leds;  /* led pointer to leds_info */
    int                  count; /* led count */
} leds_t;


/* function declaration  */
int init_led(leds_t *leds);
int term_led(leds_t *leds);
int turn_led(leds_t *leds, int which, int cmd);
static inline void msleep(unsigned long ms);


int g_stop = 0;

void sig_handler(int signum)
{
    switch( signum )
    {
        case SIGINT:
        case SIGTERM:
            g_stop = 1;

        default:
            break;
    }

    return ;
}

int main(int argc, char *argv[])
{
    int                 rv;
    leds_t              leds =
    {
        .leds  = leds_info,
        .count = LEDCNT,
    };

    if( (rv=init_led(&leds)) < 0 )
    {
        printf("initial leds gpio failure, rv=%d\n", rv);
        return 1;
    }
    printf("initial RGB Led gpios okay\n");

    signal(SIGINT,  sig_handler);
    signal(SIGTERM, sig_handler);

    while( !g_stop )
    {
        turn_led(&leds, LED_R, ON);
        msleep(DELAY);
        turn_led(&leds, LED_R, OFF);
        msleep(DELAY);

        turn_led(&leds, LED_G, ON);
        msleep(DELAY);
        turn_led(&leds, LED_G, OFF);
        msleep(DELAY);

        turn_led(&leds, LED_B, ON);
        msleep(DELAY);
        turn_led(&leds, LED_B, OFF);
        msleep(DELAY);
    }

    term_led(&leds);
    return 0;
}

int term_led(leds_t *leds)
{
    int            i;
    led_t         *led;

    printf("terminate RGB Led gpios\n");

    if( !leds )
    {
        printf("Invalid input arguments\n");
        return -1;
    }

    for(i=0; i<leds->count; i++)
    {
        led = &leds->leds[i];

        if( led->request )
        {
            turn_led(leds, i, OFF);
            gpiod_line_request_release(led->request);
        }
    }

    return 0;
}


int init_led(leds_t *leds)
{
    led_t                       *led;
    int                          i, rv = 0;
    char                         chip_dev[32];
    struct gpiod_chip           *chip;      /* gpio chip */
    struct gpiod_line_settings  *settings;  /* gpio direction, bias, active_low, value */
    struct gpiod_line_config    *line_cfg;  /* gpio line */
    struct gpiod_request_config *req_cfg;   /* gpio consumer, it can be NULL */


    if( !leds )
    {
        printf("Invalid input arguments\n");
        return -1;
    }


    /* defined in libgpiod-2.0/lib/line-settings.c:

        struct gpiod_line_settings {
            enum gpiod_line_direction direction;
            enum gpiod_line_edge edge_detection;
            enum gpiod_line_drive drive;
            enum gpiod_line_bias bias;
            bool active_low;
            enum gpiod_line_clock event_clock;
            long debounce_period_us;
            enum gpiod_line_value output_value;
        };
     */
    settings = gpiod_line_settings_new();
    if (!settings)
    {
        printf("unable to allocate line settings\n");
        rv = -2;
        goto cleanup;
    }

    /* defined in libgpiod-2.0/lib/line-config.c

        struct gpiod_line_config {
            struct per_line_config line_configs[LINES_MAX];
            size_t num_configs;
            enum gpiod_line_value output_values[LINES_MAX];
            size_t num_output_values;
            struct settings_node *sref_list;
        };
    */

    line_cfg = gpiod_line_config_new();
    if (!line_cfg)
    {
        printf("unable to allocate the line config structure");
        rv = -2;
        goto cleanup;
    }


    /* defined in libgpiod-2.0/lib/request-config.c:

        struct gpiod_request_config {
            char consumer[GPIO_MAX_NAME_SIZE];
            size_t event_buffer_size;
        };
     */
    req_cfg = gpiod_request_config_new();
    if (!req_cfg)
    {
        printf("unable to allocate the request config structure");
        rv = -2;
        goto cleanup;
    }

    for(i=0; i<leds->count; i++)
    {
        led = &leds->leds[i];

        snprintf(chip_dev, sizeof(chip_dev), "/dev/gpiochip%d", led->chip_num);
        chip = gpiod_chip_open(chip_dev);
        if( !chip )
        {
            printf("open gpiochip failure, maybe you need running as root\n");
            rv = -3;
            goto cleanup;
        }

        /* Set as output direction, active low and default level as inactive */
        gpiod_line_settings_reset(settings);
        gpiod_line_settings_set_direction(settings, GPIOD_LINE_DIRECTION_OUTPUT);
        gpiod_line_settings_set_active_low(settings, led->active);
        gpiod_line_settings_set_output_value(settings, GPIOD_LINE_VALUE_INACTIVE);

        /* set gpio line */
        gpiod_line_config_reset(line_cfg);
        gpiod_line_config_add_line_settings(line_cfg, &led->gpio_num, 1, settings);

        /* Can be NULL for default settings. */
        gpiod_request_config_set_consumer(req_cfg, led->name);

        /* Request a set of lines for exclusive usage. */
        led->request = gpiod_chip_request_lines(chip, req_cfg, line_cfg);

        gpiod_chip_close(chip);
        //printf("request %5s led[%d] for gpio output okay\n", led->name, led->gpio);
    }

cleanup:

    if( rv< 0 )
        term_led(leds);

    if( line_cfg )
        gpiod_line_config_free(line_cfg);

    if( req_cfg )
        gpiod_request_config_free(req_cfg);

    if( settings )
        gpiod_line_settings_free(settings);

    return rv;
}

int turn_led(leds_t *leds, int which, int cmd)
{
    led_t         *led;
    int            rv = 0;
    int            value = 0;

    if( !leds || which<0 || which>=leds->count )
    {
        printf("Invalid input arguments\n");
        return -1;
    }

    led = &leds->leds[which];

    value = OFF==cmd ? GPIOD_LINE_VALUE_INACTIVE : GPIOD_LINE_VALUE_ACTIVE;

    gpiod_line_request_set_value(led->request, led->gpio_num, value);

    return 0;
}

static inline void msleep(unsigned long ms)
{
    struct timespec cSleep;
    unsigned long ulTmp;

    cSleep.tv_sec = ms / 1000;
    if (cSleep.tv_sec == 0)
    {
        ulTmp = ms * 10000;
        cSleep.tv_nsec = ulTmp * 100;
    }
    else
    {
        cSleep.tv_nsec = 0;
    }

    nanosleep(&cSleep, 0);

    return ;
}

2.2.3.4 交叉编译测试运行

因为我们使用了 libgpiod 库,所以编译的时候需要指定前面我们交叉编译后安装的头文件和库文件。接下来我们修改 makefile 文件如下:

guowenxue@ubuntu20:~/igkboard/apps$ vim makefile

# Cross compiler
CROSS_COMPILE=arm-linux-gnueabihf-
CC=${CROSS_COMPILE}gcc
AR=${CROSS_COMPILE}ar

# libgpiod compile install path
LIBGPIOD_PATH=libgpiod/install/

# compile flags and link flags
CFLAGS+=-I${LIBGPIOD_PATH}/include
LDFLAGS+=-L${LIBGPIOD_PATH}/lib -lgpiod

all:
        ${CC} hello.c -o hello
        ${CC} ${CFLAGS} leds.c -o leds ${LDFLAGS}

clean:
        @rm -f hello 
        @rm -f leds

接下来使用 make 命令开始编译源文件:

guowenxue@ubuntu20:~/igkboard/apps$ make
arm-linux-gnueabihf-gcc hello.c -o hello
arm-linux-gnueabihf-gcc -Ilibgpiod/install//include leds.c -o leds -Llibgpiod/install//lib -lgpiod

前面我们介绍了如何使用 scp 命令拷贝文件到开发板上,接下来我们使用 tftp 命令通过 TFTP (Trivial File Transfer Protocol,简单文件传输协议) 协议从服务器上下载文件。很显然,这种方式我们在 Windows 或 Linux 服务器上开启了 TFTP 服务。

Linux下的 TFTP服务器搭建这里就不作介绍,大家百度搜索解决。而Windows下的 TFTP 服务器搭建则比较简单,直接 点此链接下载tftpd程序 运行即可。

首先将 前面编译生成的 leds 文件拷贝到 TFTP 服务的根路径(我这里配置的是 /tftp )下 :

guowenxue@ubuntu20:~/igkboard/apps$ cp leds /tftp

然后在开发板上使用 tftp 命令下载并运行。

root@igkboard:~# tftp -gr leds 192.168.2.2
root@igkboard:~# chmod a+x leds
root@igkboard:~# ./leds 
initial RGB Led gpios okay

此时,我们将会看到三色灯模块按照 红、绿、蓝 的顺序依次闪烁,闪烁时间间隔为 600ms。

2.3 Input设备编程之按键控制

版权声明

本文档所有内容文字资料由凌云实验室郭工编著,主要用于凌云嵌入式Linux教学内部使用,版权归属作者个人所有。任何媒体、网站、或个人未经本人协议授权不得转载、链接、转帖或以其他方式复制发布/发表。已经授权的媒体、网站,在下载使用时必须注明来源,违者本人将依法追究责任。

  • Copyright (C) 2021 凌云物网智科实验室·郭工

  • Author: GuoWenxue <guowenxue@gmail.com> QQ: 281143292

wechat_pub

2.3.1 input 子系统简介

Input子系统是Linux对输入设备提供的统一驱动框架。如按键、键盘、触摸屏和鼠标等输入设备的驱动方式是类似的,当出现按键、触摸等操作时,硬件产生中断,然后CPU直接读取引脚电平,或通过SPI、I2C等通讯方式从设备的寄存器读取具体的按键值或触摸坐标,然后把这些信息提交给内核。

使用Input子系统 驱动的输入设备可以通过统一的数据结构提交给内核,该数据结构包括输入的时间、类型、代号以及具体的键值或坐标,而内核则通过 /dev/input 目录下的设备文件接口传递给用户空间。

root@igkboard:~# ls /dev/input/
by-path  event0  event1

作为应用开发人员,可以只基于API使用输入子系统。但是了解内核中输入子系统的框架、了解数据流程,有助于解决开发过程中碰到的硬件问题、驱动问题。Linux系统下的输入系统框架如下图所示:

input_frame

假设用户程序直接访问 /dev/input/event0 设备节点,或者使用tslib访问触摸屏设备节点,数据的流向如下:

  1. 应用程序open()打开输入设备文件后调用read()读数据,此时输入设备没有事件发生,则读不到数据阻塞;

  2. 用户操作设备(如按下设备、点击触摸屏),硬件上产生中断;

  3. 输入系统驱动层对应的驱动程序处理中断:读取到数据,转换为标准的输入事件(一个struct input_event结构体),向核心层汇报。

  4. 核心层可以决定把输入事件转发给上面哪个handler来处理。从handler的名字来看,它就是用来处输入操作的。比如:evdev_handler、kbd_handler、joydev_handler等。

  5. 他们作用就是把核心层的数据返回给正在读取的APP,当APP正在调用read()系统调用等待数据时,evdev_handler会把它唤醒,这样APP就可以返回数据。

2.4.2 input事件目录

2.4.2.1 input事件结构

应用程序空间在从input设备read()读取数据时,它的每个数据元素是struct input_event 结构体类型,该结构体在Linux内核源码中其定义在 include/uapi/linux/input.h 文件中,而应用程序空间则定义在 /usr/include/linux/input.h 文件中。

该结构体的定义如下:

struct input_event {
	struct timeval time;
    __u16 type;
    __u16 code;
    __s32 value;
};

typedef long		__kernel_long_t;
typedef __kernel_long_t	__kernel_old_time_t;
typedef __kernel_long_t		__kernel_suseconds_t;

//Linux内核源码: include/uapi/linux/time.h 
//应用编程头文件: /usr/include/linux/time.h
struct timeval {  
	__kernel_old_time_t	    tv_sec;		/* seconds */
	__kernel_suseconds_t	tv_usec;	/* microseconds */
};
  • time:该变量用于记录事件产生的时间戳。表示“自系统启动以来过了多少时间”,由秒和微秒(long 类型 32bit)组成。

  • type:输入设备的事件类型。系统常用的默认类型有EV_KEY、 EV_REL和EV_ABS,分别用于表示按键状态改变事件、相对坐标改变事件及绝对坐标改变事件。其类型定义如下:

    /*
     * Event types
     * Linux内核源码: include/uapi/linux/input-event-codes.h
     * 应用编程头文件: /usr/include/linux/input-event-codes.h
     */ 
    
    #define EV_SYN			0x00
    #define EV_KEY			0x01
    #define EV_REL			0x02
    #define EV_ABS			0x03
    #define EV_MSC			0x04
    #define EV_SW			0x05
    #define EV_LED			0x11
    #define EV_SND			0x12
    #define EV_REP			0x14
    #define EV_FF			0x15
    #define EV_PWR			0x16
    #define EV_FF_STATUS		0x17
    #define EV_MAX			0x1f
    #define EV_CNT			(EV_MAX+1)
    
  • code:事件代号,表示该类事件下的哪一个事件。例如 在EV_KEY事件类型中,code的值常用于表示键盘上具体的按键,比如数字键1、2、3,字母键A、B、C里等。查看定义

  /*
   * Keys and buttons
   * Linux内核源码: include/uapi/linux/input-event-codes.h
   * 应用编程头文件: /usr/include/linux/input-event-codes.h 
   */
  #define KEY_RESERVED		0
  #define KEY_ESC			1
  #define KEY_1			2
  #define KEY_2			3
  #define KEY_3			4
  #define KEY_4			5
  #define KEY_5			6
  #define KEY_6			7
  #define KEY_7			8
  #define KEY_8			9
  #define KEY_9			10
  #define KEY_0			11
  #define KEY_MINUS		12
  #define KEY_EQUAL		13
  #define KEY_BACKSPACE		14
  #define KEY_TAB			15
  #define KEY_Q			16
  ...
  • value :事件的值。对于EV_KEY事件类型,当按键按下时,该值为1;按键松开时,该值为0。

2.4.2.2 input事件设备名

查看/dev/input可以看到很多event*节点,事件编号与设备的联系不是固定的,它通常按系统检测到设备的先后顺序安排event文件的编号。例如:在IGKBoard开发板上查看/dev/input文件夹下,有2个event事件编号。

root@igkboard:~# ls /dev/input/
by-path  event0  event1

下面提供三个方法查看event编号对应的具体的硬件设备:

  1. 查看/dev/input/by-path目录查看事件编号对应的具体的硬件设备

root@igkboard:~# ls -l /dev/input/by-path/   
total 0
lrwxrwxrwx 1 root root 9 Apr 29 04:06 platform-20cc000.snvs:snvs-powerkey-event -> ../event0
lrwxrwxrwx 1 root root 9 Apr 29 04:06 platform-keys-event -> ../event1

该目录下的文件实际上都是链接,如 event1 对应的就是访问 IGKBoard开发板上板载用户按键的设备。由于/dev下的设备都是通过/sys导出的,所以也可以通过 /sys/class/input 目录查看。

root@igkboard:~# ls /sys/class/input/
event0  event1  input0  input1

root@igkboard:~# ls /sys/class/input/event1/
dev  device  power  subsystem  uevent

root@igkboard:~# ls /sys/class/input/event1/device
capabilities  device  event1  id  inhibited  modalias  name  phys  power  properties  subsystem  uevent  uniq

root@igkboard:~# cat /sys/class/input/event1/device/name  
keys
  1. 查看/proc/bus/input/devices文件查看事件编号对应的具体的硬件设备

root@igkboard:~# cat /proc/bus/input/devices 
I: Bus=0019 Vendor=0000 Product=0000 Version=0000
N: Name="20cc000.snvs:snvs-powerkey"
P: Phys=snvs-pwrkey/input0
S: Sysfs=/devices/platform/soc/2000000.bus/20cc000.snvs/20cc000.snvs:snvs-powerkey/input/input0
U: Uniq=
H: Handlers=kbd event0 
B: PROP=0
B: EV=3
B: KEY=100000 0 0 0

I: Bus=0019 Vendor=0001 Product=0001 Version=0100
N: Name="keys"
P: Phys=gpio-keys/input0
S: Sysfs=/devices/platform/keys/input/input1
U: Uniq=
H: Handlers=kbd event1 
B: PROP=0
B: EV=100003
B: KEY=10000000

​ 可以看到keys对应的就是event1(其H: Handlers=kbd event1 ),下面是每一个设备信息中的I、N、P、S、U、H、B对应的含义:

  • I:id of the device 设备ID

  • N:name of the device 设备名称

  • P:physical path to the device in the system hierarchy 系统层次结构中设备的物理路径

  • S:sysfs path 位于sys文件系统的路径

  • U:unique identification code for the device 设备的唯一标识码

  • H:list of input handles associated with the device 与设备关联的输入句柄列表

  • B:bitmaps 位图

    • PROP:设备属性

    • EV:设备支持的事件类型

    • KEY:此设备具有的键/按钮

    • MSC:设备支持的其他事件

    • LED:设备上的指示灯

解释Bitmap位图: 比如第二个设备的 “B: EV=b”,用来表示该设备支持哪类输入事件。b的二进制是1011,即bit0、bit1、bit3使能,表示该设备支持0、1、3这三类事件,通过查看struct input_event中type的事件类型可以知道是,EV_SYNEV_KEYEV_ABS。至于其他ABS=和KEY=是相应硬件特定的属性值。

  1. 使用evtest工具查看事件编号对应的具体的硬件设备

在开发input子系统驱动时,常常会使用 evtest 工具进行测试,它列出了系统当前可用的/dev/input/event0~2输入事件 文件,并且列出了这些事件对应的设备名。具体如下所示:

root@igkboard:~# evtest 
No device specified, trying to scan all of /dev/input/event*
Available devices:
/dev/input/event0:      20cc000.snvs:snvs-powerkey
/dev/input/event1:      keys
Select the device event number [0-1]: 1

2.4.4 开发板按键检测实验

2.4.4.1 使用命令行工具检测

IGKBoard开发板可以使用两种方式读取event事件: evtesthexdump

evtest 命令使用

在命令行输入evtest命令 ,然后选择需要检测的Input设备对应的event编号,如下可知设备 event2 对应着开发板上的用户按键,然后按下一次用户按键(位置如下图所示)。

root@igkboard:~# evtest
No device specified, trying to scan all of /dev/input/event*
Available devices:
/dev/input/event0:      20cc000.snvs:snvs-powerkey
/dev/input/event1:      keys
Select the device event number [0-1]: 1
Input driver version is 1.0.1
Input device ID: bus 0x19 vendor 0x1 product 0x1 version 0x100
Input device name: "keys"
Supported events:
  Event type 0 (EV_SYN)
  Event type 1 (EV_KEY)
    Event code 28 (KEY_ENTER)
Key repeat handling:
  Repeat type 20 (EV_REP)
    Repeat code 0 (REP_DELAY)
      Value    250
    Repeat code 1 (REP_PERIOD)
      Value     33
Properties:
Testing ... (interrupt to exit)
Event: time 1714372360.795051, type 1 (EV_KEY), code 28 (KEY_ENTER), value 1
Event: time 1714372360.795051, -------------- SYN_REPORT ------------
Event: time 1714372360.964434, type 1 (EV_KEY), code 28 (KEY_ENTER), value 0
Event: time 1714372360.964434, -------------- SYN_REPORT ------------
  • 输入编号后它列出了 event1 的一些设备信息,包括驱动版本、设备ID、设备名、支持的事件类型、事件代号以及输入值的取值范围。

  • 此时按下开发板按键(按键位置如下图所示),可以看到它输出了详细的事件信息。输出信息中每一行包含了按键上报事件的具体时间time、事件类型type 1(EV_KEY)、事件代号code 28 和 具体的值value,该值就是按键按下 (1) 和释放上报的值 (0) 。

  • 还有一个SYN_REPORT 即为同步事件。同步事件用于实现同步操作、告知接收者本轮上报的数据已经完整。

userkey_image

hexdump 命令使用

linux系统下使用 hexdump 命令可以以16进制形式查看任何文件的数据内容。这样,我们也可以使用它来读取按键设备按下按键时按下的event数据包。如下所示,在开发板上输入以下命令,然后按下一次用户按键。

root@igkboard:~# hexdump /dev/input/event1
0000000 3f91 662f 6861 0009 0001 001c 0001 0000
0000010 3f91 662f 6861 0009 0000 0000 0000 0000
0000020 3f91 662f f3cf 000b 0001 001c 0000 0000
0000030 3f91 662f f3cf 000b 0000 0000 0000 0000

解析第一行数据(都是以16进制显示)

  • 0000000:序列,一行有16字节数据;

  • 3f91 662f:秒(时间)

  • 6861 0009:微秒(时间)

  • 0001:type,对应EV_KEY

  • 001c:code,对应KEY_ENTER

  • 0001 0000:value,对应值为1

  • 第二行数据为一次同步事件

  • 第三行数据为按键释放时候,value值为0

  • 第四行数据为一次同步事件

2.4.4.2 按键测试程序应用编程

编写按键测试程序的源代码 keypad.c 如下。

guowenxue@ubuntu20:~/igkboard/apps$ vim keypad.c 
/*********************************************************************************
 *      Copyright:  (C) 2021 Guo Wenxue<Email:guowenxue@gmail.com QQ:281143292>
 *                  All rights reserved.
 *
 *       Filename:  keypad.c
 *    Description:  This file used to test GPIO button driver builtin Linux kernel
 *                 
 *        Version:  1.0.0(11/17/2021~)
 *         Author:  Guo Wenxue <guowenxue@gmail.com>
 *      ChangeLog:  1, Release initial version on "11/17/2021 02:46:18 PM"
 *                 
 ********************************************************************************/

#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <libgen.h>
#include <getopt.h>
#include <sys/types.h>
#include <sys/ioctl.h>
#include <linux/input.h>
#include <linux/kd.h>
#include <linux/keyboard.h>

#if 0 /* Just for comment here, Reference to linux-3.3/include/linux/input.h */
struct input_event 
{
    struct timeval time;
    __u16 type;  /* 0x00:EV_SYN 0x01:EV_KEY 0x04:EV_MSC 0x11:EV_LED*/
    __u16 code;  /* key value, which key */
    __s32 value; /* 1: Pressed  0:Not pressed  2:Always Pressed */
};  
#endif

#define EV_RELEASED        0
#define EV_PRESSED         1

#define BUTTON_CNT         10

/* 在C语言编程中,函数应该先定义再使用,如果函数的定义在函数调用后面,应该前向声明。*/
void usage(char *name);

void display_button_event(struct input_event *ev, int cnt);

int main(int argc, char **argv)
{
    char                  *kbd_dev = "/dev/input/event1";  //默认监听按键设备;
    char                  kbd_name[256] = "Unknown"; //用于保存获取到的设备名称
    int                   kbd_fd = -1;  //open()打开文件的文件描述符 
    int                   rv=0;  // 函数返回值,默认返回0;
    int                   opt;    // getopt_long 解析命令行参数返回值;
    int                   size = sizeof (struct input_event);
    fd_set                rds; //用于监听的事件的集合

    struct input_event    ev[BUTTON_CNT]; 

    /* getopt_long参数函数第四个参数的定义,二维数组,每个成员由四个元素组成 */
    struct option long_options[] = {  
         /* { 参数名称,是否带参数,flags指针(NULL时将val的数值从getopt_long的返回值返回出去),
            函数找到该选项时的返回值(字符)}
         */
        {"device", required_argument, NULL, 'd'},
        {"help", no_argument, NULL, 'h'},
        {NULL, 0, NULL, 0}
    };

    //获取命令行参数的解析返回值
    while ((opt = getopt_long(argc, argv, "d:h", long_options, NULL)) != -1) 
    { 
        switch (opt) 
        {
            case 'd':
                kbd_dev = optarg; 
                break;

            case 'h':
                usage(argv[0]);
                return 0;

            default:
                break;
        }
    }

    if(NULL == kbd_dev)
    {
        /* 命令行argv[0]是输入的命令,如 ./keypad */
        usage(argv[0]); 
        return -1;
    }

    /* 获取uid 建议以root权限运行确保可以正常运行 */
    if ((getuid ()) != 0)  
        printf ("You are not root! This may not work...\n");

    /* 打开按键对应的设备节点,如果错误则返回负数 */
    if ((kbd_fd = open(kbd_dev, O_RDONLY)) < 0)
    {
        printf("Open %s failure: %s", kbd_dev, strerror(errno));
        return -1;
    }

    /* 使用ioctl获取 /dev/input/event*对应的设备名字 */
    ioctl (kbd_fd, EVIOCGNAME (sizeof (kbd_name)), kbd_name);
    printf ("Monitor input device %s (%s) event on poll mode:\n", kbd_dev, kbd_name);

    /* 循环使用 select() 多路复用监听按键事件 */
    while (1)
    {
        FD_ZERO(&rds); /* 清空 select() 的读事件集合 */
        FD_SET(kbd_fd, &rds); /* 将按键设备的文件描述符加入到读事件集合中*/

        /* 使用select开启监听并等待多个描述符发生变化,第一个参数最大描述符+1,
           2、3、4参数分别是要监听读、写、异常三个事件的文军描述符集合;
           最后一个参数是超时时间(NULL-->永不超时,会一直阻塞住)
           
           如果按键没有按下,则程序一直阻塞在这里。一旦按键按下,则按键设备有数据
           可读,此时函数将返回。
        */
        rv = select(kbd_fd + 1, &rds, NULL, NULL, NULL);
        if (rv < 0) 
        {
            printf("Select() system call failure: %s\n", strerror(errno));
            goto CleanUp;
        }
        else if (FD_ISSET(kbd_fd, &rds)) /* 是按键设备发生了事件 */
        { 
            //read读取input设备的数据包,数据包为input_event结构体类型。
            if ((rv = read (kbd_fd, ev, size*BUTTON_CNT )) < size) 
            {
                printf("Reading data from kbd_fd failure: %s\n", strerror(errno));
                break;
            }
            else
            {
                display_button_event(ev, rv/size);
            }
        }
    }

CleanUp:
    close(kbd_fd);

    return 0;
}

/* 该函数用来打印程序的使用方法 */
void usage(char *name)
{
    char *progname = NULL;
    char *ptr = NULL;

    /* 字符串拷贝函数,该函数内部将调用malloc()来动态分配内存,然后将$name
       字符串内容拷贝到malloc分配的内存中,这样使用完之后需要free释放内存. */
    ptr = strdup(name); 
    progname = basename(ptr); //去除该可执行文件的路径名,获取其自身名称(即keypad)

    printf("Usage: %s [-p] -d <device>\n", progname);
    printf(" -d[device  ] button device name\n");
    printf(" -p[poll    ] Use poll mode, or default use infinit loop.\n");
    printf(" -h[help    ] Display this help information\n"); 

    free(ptr);  //和strdup对应,释放该内存
    return;
}

/* 该函数用来解析按键设备上报的数据,并答应按键按下的相关信息 */
void display_button_event(struct input_event *ev, int cnt)
{
    int i;
    static struct timeval pressed_time;  //该变量用来存放按键按下的时间,注意static的使用。
    struct timeval        duration_time; //该变量用来存放按键按下持续时间 

    for(i=0; i<cnt; i++)
    {
        /* 当上报的时间type为EV_KEY时候并且,value值为1或0 (1为按下,0为释放) */
        if(EV_KEY==ev[i].type && EV_PRESSED==ev[i].value)
        {
            pressed_time = ev[i].time;
            printf("Keypad[%d] pressed time: %ld.%ld\n", 
                   ev[i].code, pressed_time.tv_sec, pressed_time.tv_usec);
        }
        if(EV_KEY==ev[i].type && EV_RELEASED==ev[i].value)
        {
            /* 计算时间差函数 将第一个参数减去第二个参数的值的结果 放到第三个参数之中 */
            timersub(&ev[i].time, &pressed_time, &duration_time);
            printf("keypad[%d] released time: %ld.%ld\n", 
                   ev[i].code, ev[i].time.tv_sec, ev[i].time.tv_usec);
            printf("keypad[%d] duration time: %ld.%ld\n", 
                   ev[i].code, duration_time.tv_sec, duration_time.tv_usec);
        }
    }
}

2.4.4.3 交叉编译测试运行

程序编写好之后,接下来我们再次修改 makefile 文件,添加 keypad 程序的编译支持。

guowenxue@ubuntu20:~/igkboard/apps$ cat makefile 
... ...
all:
        ... ...
        ${CC} keypad.c -o keypad        

clean:
        ... ...
        @rm -f keypad

然后使用 make 命令来交叉编译程序。

guowenxue@ubuntu20:~/igkboard/apps$ make

现在我们在开发板上通过 scp 命令或其它方式将编译生成的测试程序下载到开发板上。

root@igkboard:~# scp -P 2288 guowenxue@192.168.2.2:~/igkboard/apps/keypad .     
guowenxue@192.168.2.2's password: 
keypad                                      100%   13KB 570.9KB/s   00:00  

接下来,我们给该程序加上执行权限并运行。接着按下开发板上的用户按键并释放,可以看到按键按下、释放和持续时间。

root@igkboard:~# chmod a+x keypad 

root@igkboard:~# ./keypad -d /dev/input/event1
Monitor input device /dev/input/event1 (keys) event on poll mode:
Keypad[28] pressed time: 1714372971.244532
keypad[28] released time: 1714372971.408718
keypad[28] duration time: 0.164186

2.4 DS18B20温度传感器采样

版权声明

本文档所有内容文字资料由凌云实验室郭工编著,主要用于凌云嵌入式Linux教学内部使用,版权归属作者个人所有。任何媒体、网站、或个人未经本人协议授权不得转载、链接、转帖或以其他方式复制发布/发表。已经授权的媒体、网站,在下载使用时必须注明来源,违者本人将依法追究责任。

  • Copyright (C) 2022 凌云物网智科实验室·郭工

  • Author: Guo Wenxue Email: guowenxue@gmail.com QQ: 281143292

wechat_pub

2.4.1 DS18b20传感器简介

DS18B20 (淘宝购买链接) 是由 Dallas 半导体公司推出的一种的一线总线(1-Wire)接口的数字温度传感器,它工作在 3v~5.5V的电压范围,其测量温度范围为-55~+125℃ ,精度为±0.5℃。与传统的热敏电阻等测温元件相比,它是一种新型的体积小、适用电压宽、接口简单的数字化温度传感器,并采用多种封装形式,从而使系统设计灵活、方便。

ds18b20

DS18B20根据不同的应用场合而改变其外观,封装后的DS18B20可用于电缆沟测温,高炉水循环测温,锅炉测温,机房测温,农业大棚测温,洁净室测温,弹药库测温等各种非极限温度场合。耐磨耐碰,体积小,使用方便,封装形式多样,适用于各种狭小空间设备数字测温和控制领域。

ds18b20_package

DS18B20温度传感器主要具有以下功能特性:

  • 它的工作电压范围为3.0v~5.0v,另外也可以直接由数据线供电而不需要外部电源供电;

  • 采用一线协议(1-Wire),即仅使用一根数据线(以及地线)与微控制器(MCU)进行通信;

  • 它可以提供9-Bit到12-Bit的测量精度和一个用户可编程的非易失性且具有过温和低温触发报警的报警功能;

  • 该传感器的温度检测范围为-55℃至+125℃,并且在温度范围超过-10℃至85℃之外时还具有+-0.5℃的精度;

  • DS18B20温度转换时间在转换精度为12-Bits时达到最大值750ms;

每个DS18B20芯片在出厂时,都固化烧录了一个唯一的64位产品序列号在其ROM中,它可以看作是该 DS18B20 的地址序列码。 64 位 ROM 的排列是:前 8 位是产品家族码,接着 48 位是DS18B20 的序列号,最后 8 位是前面 56 位的循环冗余校验码(CRC=X8+X5+X4+1)。 ROM 作用是使每一个 DS18B20 都各不相同,这样就可实现一根总线上挂接多个 DS18B20。

接下来,我们将深入学习并了解DS18B20温度传感器的工作原理,需要注意的是在学习的过程中,一定要对着芯片的datasheet来理解点此链接可以下载或在线查阅。

2.4.2 DS18B20工作原理

2.4.2.1 1-Wire协议

一线总线结构具有简洁且经济的特点,可使用户轻松地组建传感器网络,从而为测量系统的构建引入全新概念。现场温度直接以“一线总线”的数字方式传输,大大提高了系统的抗干扰性。它能直接读出被测温度,并且可根据实际要求通过简单的编程实现 9~12 位的数字值读数方式。

所有的单总线器件要求采用严格的信号时序,以保证数据的完整性。 DS18B20 共有 6 种信号类型:复位脉冲、应答脉冲、写 0/ 1、读 0/1。所有这些信号,除了应答脉冲以外,都由主机发出同步信号,并且发送所有的命令和数据都是字节的低位在前(LSB)。

1)复位脉冲和应答脉冲

单总线上的所有通信都是以初始化序列开始。主机(CPU)输出低电平,并保持低电平时间至少 480us,以产生复位脉冲。接着主机释放总线, 4.7K 的上拉电阻将单总线拉高,延时 15~60 us,并进入接收模式(Rx)。DS18B20芯片在收到主机发送过来的这个复位脉冲后,将会拉低总线 60~240 us,以产生低电平应答脉冲,然后释放总线并维持至少480us。CPU在这段期间如果读到低电平,则说明探测到DS18B20芯片,否则DS18B20芯片损坏或芯片并没有连接。

w1_reset

2)写时序

写时序包括写 0 时序和写 1 时序。所有写时序至少需要 60us,且在 2 次独立的写时序之间至少需要 1us 的恢复时间,两种写时序均起始于主机拉低总线:

  • 写 0 时序:主机输出低电平,延时 60us,然后释放总线,延时 2us;

  • 写 1 时序:主机输出低电平,延时 2us,然后释放总线,延时 60us;

w1_write

3)读时序

单总线器件(DS18B20)仅在主机发出读时序时,才向主机传输数据,所以,在主机发出读数据命令后,必须马上产生读时序,以便从机能够传输数据。所有读时序至少需要 60us,且在 2 次独立的读时序之间至少需要 1us 的恢复时间。

当总线控制器把数据线从高电平拉到低电平时,读时序开始,数据线必须至少保持1us,然后总线被释放。DS18B20 通过拉高或拉低总线上来传输”1”或”0”。当传输逻辑”0”结束后,总线将被释放,通过上拉电阻回到上升沿状态,从DS18B20输出的数据在读时序的下降沿出现后15us 内有效。因此,总线控制器在读时序开始后必须停止把I/O口驱动为低电15us,以读取I/O口状态。

w1_read

2.4.2.2 DS18B20工作流程

DS18B20传感器的工作流程为:

  1. 总线初始化;

  2. ROM操作命令;

  3. 存储器操作命令;

  4. 处理数据;

1)总线初始化

单总线上的所有通信都是以初始化序列开始,Master发出初始化信号后等待从设备的应答信号,已确定从设备是否存在并能正常工作。

2)ROM操作命令

总线主机检测到DS18B20的存在后,便可以发出 ROM 操作命令之一,这些命令如下表所示。一般我们不关心ROM中的16位产品序列号,通常会发送0xCC跳过ROM的相关操作。

指令说明

十六进制代码

Read ROM(读 ROM)

[33H]

Match ROM(匹配 ROM)

[55H]

Skip ROM(跳过 ROM]

[CCH]

Search ROM(搜索 ROM)

[F0H]

Alarm search(告警搜索)

[ECH]

3)存储器操作命令

ROM命令操作完成之后,接下来可以发送相应的高速暂存存储器操作命令,这些命令如下表所示。其中0x44命令将通知DS18B20温度传感器开始采样, 而0xBE命令则将开始读出DS18B20的采样值。

指令说明

十六进制代码

Write Scratchpad(写暂存存储器)

[4EH]

Read Scratchpad(读暂存存储器)

[BEH]

Copy Scratchpad(复制暂存存储器)

[48H]

Convert Temperature(温度变换)

[44H]

Recall EPROM(重新调出)

[B8H]

Read Power supply(读电源)

[B4H]

3)数据处理

DS18B20的高速暂存存储器由9个字节组成。当温度转换命令(0x44)发布后,经转换所得的温度值以二字节补码形式存放在高速暂存存储器前两个字节。接着单片机可以发送读暂存存储器命令(0xBH)读出存储器里的值, 存储器里的9个字节的存储结构如下图所示:

memory_map

  • 字节0~1 是温度存储器,用来存储转换好的温度。第0个字节存储温度低8位,第一个字节存储温度高8位;

  • 字节2~3 是用户用来设置最高报警和最低报警值(TH和TL)。

  • 字节4 是配置寄存器,用来配置转换精度,可以设置为9~12 位。

  • 字节5~7 保留位。芯片内部使用

  • 字节8 CRC校验位。是64位ROM中的前56位编码的校验码,由CRC发生器产生。

如果我们只关心采样温度值的话,则只需要读前两个字节即可。其中Byte[0]为温度值的低字节,而Byte[1]为温度值的高字节。这16位数据的格式如下图所示:

reg_format

  • BIT[3:0]为温度值的小数部分;

  • BIT[10:4]为温度值的整数部分;

  • BIT[15:11]则为符号位,如果为0则温度为正值,如果为1则温度为负值。

2.4.3 DS18B20温度采样编程

2.4.3.1 模块硬件连接说明

DS18B20传感器的工作电压范围为3~5.5v,所以其电源连接3.3v和5v都可以(建议连接3.3V)。这样DS18B20在与开发板相连时,主要连接如下三个引脚:

  1. GND,该引脚要连到开发板的GND扩展引脚上;

  2. VDD, 该引脚要连到开发板的 3.3v 或 5v 供电引脚上;

  3. DQ, 是DS18B20的数据通信引脚,该引脚应该连开发板上具有 1-Wire 协议功能的GPIO引脚上;

在IGKBoard开发板上,提供了与树莓派兼容的 40Pin扩展引脚,其定义如下。其中物理引脚 #7 (GPIO1_IO18) 在Linux系统启动时如果启用了 w1 overlay 后,它将会默认作为 DS18B20 传感器的一线协议接口使用。这样,DS18B20的 DQ 引脚应该连接它上。

40pin_ds18b20

如下是 DS18B20 温度传感器连接到 IGKBoard上的实物示意图。

igkboard_ds18b20

2.4.3.2 驱动配置使用说明

在前面,我们将DS18B20的DQ引脚连到了IGKBoard开发板扩展接口的 #7 引脚上(GPIO01_IO18),该引脚在系统启动时有可能 默认作为GPIO功能使用。如果想作为DS18B20的通信引脚使用的话,我们需要修改开发板上的DTOverlay配置文件,添加该引脚的 1-Wire 协议支持。

具体方法为修改 eMMC/TF卡 启动介质的 boot 分区下的 config.txt 文件,修改 dtoverlay_w1 选项中添加 w1 支持即可。

root@igkboard:~# vim /run/media/mmcblk1p1/config.txt 

# Eanble 1-Wire overlay
dtoverlay_w1=yes

root@igkboard:~# sync

如果系统默认没有挂载这个分区,则需要先使用 mount 命令来手动挂载。

root@igkboard:~# mount | grep mmcblk
/dev/mmcblk1p2 on / type ext4 (rw,relatime)

root@igkboard:~# mkdir -p /run/media/mmcblk1p1/
root@igkboard:~# mount /dev/mmcblk1p1 /run/media/mmcblk1p1/

修改完成后重启系统,系统启动时将会自动加载 1-wire 协议驱动。待系统启动完成后,我们可以使用 dmesg 命令确认驱动是否成功加载。

root@igkboard:~# dmesg | grep 1-wire
[    1.897063] Driver for 1-wire Dallas network protocol.

接下来,我们应该可以在 /sys/bus/w1/devices/ 路径下,看到 DS18B20 温度传感器的相关文件 28-xxxxxx,事实上它是一个链接到其它文件夹的符号连接,我们暂时不关心。如下命令执行的结果所示,其中 0417012373ff 为现在所使用的DS18B20芯片的产品序列号。需要注意的是,板子上连接不同的DS18B20芯片这个值就不同。

root@igkboard:~# ls /sys/bus/w1/devices/
28-0417012373ff  w1_bus_master1

我们使用 ls 命令看看,这个文件夹下有哪些文件。在这里,最重要的一个文件是 w1_slave 文件。当我们在Linux下开始读该文件时,它将触发Linux内核中的 DS18B20 驱动开始采样。在采样完成之后,Linux内核驱动将会把采样温度值写入到该文件中,这样我们读取该文件里的内容即可。

root@igkboard:~# ls /sys/bus/w1/devices/28-0417012373ff
alarms     eeprom_cmd  hwmon  power       temperature
conv_time  ext_power   id     resolution  uevent
driver     features    name   subsystem   w1_slave

现在,我们可以使用 cat 命令查看 w1_slave 文件里的内容,注意这里只能使用 cat 命令而不能使用 vim 命令查看,因为该文件是只读的。另外在执行这个命令的时候,我们会发现执行 cat 命令之后,结果需要延时一会才会显示出来。这是因为,Linux内核在触发传感器采样,并等待采样完成、写入采样温度值到该文件中还需要一段时间。

root@igkboard:~# cat /sys/bus/w1/devices/28-0417012373ff/w1_slave 
93 01 4b 46 7f ff 0c 10 f6 : crc=f6 YES
93 01 4b 46 7f ff 0c 10 f6 t=25187

在上面的 输出结果中:

crc=f6 : 它是DS18B20采样时传输数据的CRC校验和,主要用来校验传感器将采样数据发送给CPU时是否出错;

t=25187 : 后面的 25187就是采样温度值,其中25是整数部分,187为小数部分,即本次采样温度为 25.187℃。

2.4.3.3 测试程序应用编程

在前面我们知道,DS18B20采样后的温度值存放在 **/sys/bus/w1/devices/28-0621c148b27d/w1_slave **文件中,这里的 28-0621c148b27d 是DS18B20的产品序列号,不同的DS18B20芯片其序列号不同。所以在编写代码时,我们不能直接读这个路径的文件来获取温度,因为传感器或设备换了之后,我们的代码就不能工作了。

这样,我们在编程时就应该能够根据不同的芯片来动态获取这个文件路径。因为DS18B20的温度传感器文件总是在 /sys/bus/w1/devices/ 路径下,并且传感器文件夹名是以 “28-” 开头,所以我们可以通过打开文件夹**/sys/bus/w1/devices/**,并在里面找到以 “28-” 开头的文件。然后再使用相应的字符串处理函数把它们组合成一个完整的路径: **/sys/bus/w1/devices/28-xxxxxxxxxxxx/w1_slave **。

接下来,我们编写 DS18B20传感器采样获取温度的代码 ds18b20.c 如下。

guowenxue@ubuntu20:~/igkboard/apps$ vim ds18b20.c

 /*********************************************************************************
 *      Copyright:  (C) 2023 LingYun IoT System Studio
 *                  All rights reserved.
 *
 *       Filename:  ds18b20.c
 *    Description:  This file is temperature sensor DS18B20 source code
 *
 *        Version:  1.0.0(2023/8/10)
 *         Author:  Guo Wenxue <guowenxue@gmail.com>
 *      ChangeLog:  1, Release initial version on "2023/8/10 12:13:26"
 *
 * Pin connection:
 *
 *               DS18B20 Module          IGKBoard
 *                   VCC      <----->      3.3V
 *                   DQ       <----->      #Pin7(GPIO1_IO18)
 *                   GND      <----->      GND
 *
 * /run/media/mmcblk1p1/config.txt:
 *
 *          # Eanble 1-Wire overlay
 *          dtoverlay_w1=yes
 *
 ********************************************************************************/

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <dirent.h>
#include <string.h>
#include <time.h>
#include <errno.h>

int ds18b20_get_temperature(float *temp);

int main(int argc, char *argv[])
{
    float       temp; /* 温度值有小数位,所以使用浮点数 */

    if( ds18b20_get_temperature(&temp) < 0 )
    {
        printf("ERROR: ds18b20 get temprature failure\n");
        return 1;
    }

    /* 打印DS18B20的采样温度值,因为℃是非ASCII打印字符,所以这里用 'C 代替 */
    printf("DS18B20 get temperature: %f 'C\n", temp);

    return 0;
}

/*
 * 函数说明: 该函数用来使用 DS18B20 温度传感器采样获取当前的温度值;
 * 参数说明: $temp: 通过指针返回DS18B20的采样温度
 * 返回说明: ==0 表示成功, <0 表示失败
 */
int ds18b20_get_temperature(float *temp)
{
    const char     *w1_path = "/sys/bus/w1/devices/";
    char            ds_path[50];
    char            chip[20];
    char            buf[128];
    DIR            *dirp;
    struct dirent  *direntp;
    int             fd =-1;
    char           *ptr;
    int             found = 0;
    int             rv = 0;


    /* 在C语言编程时,进入函数的第一件事应该进行函数参数的合法性检测,检查参数非法输入。
     * 否则调用者"不小心"通过 $temp 传入一个空指针,下面的代码就有可能出现段错误。
     */
    if( !temp )
    {
        return -1;
    }

    /* 打开 "/sys/bus/w1/devices/" 文件夹,如果打开失败则打印错误信息并退出。 */
    if((dirp = opendir(w1_path)) == NULL)
    {
        printf("opendir error: %s\n", strerror(errno));
        return -2;
    }

    /* 1, 因为文件夹下可能有很多文件,所以这里使用while()循环读取/sys/bus/w1/devices/
     * 文件夹下的所有目录项,其中 direntp->d_name 就是目录里的每个文件/文件夹的文件名。
     *
     * 2, 接下来我们使用 strstr() 函数判断文件名中是否包含 "28-",如果找到则将完整的
     * 文件名通过strcpy()函数保存到 chip 中;并设置 found 标志为1,跳出循环。
     */
    while((direntp = readdir(dirp)) != NULL)
    {
        if(strstr(direntp->d_name,"28-"))
        {
            /* find and get the chipset SN filename */
            strcpy(chip,direntp->d_name);
            found = 1;
            break;
        }
    }

    /* 文件夹打开用完后,要记得第一时间关闭 */
    closedir(dirp);

    /* found在定义时初始化为0,如果上面没有找到 "28-" 文件则其值依然为0,否则将被置为1 */
    if( !found )
    {
        printf("Can not find ds18b20 in %s\n", w1_path);
        return -3;
    }

    /* 使用snprintf() 生成完整路径/sys/bus/w1/devices/28-xxxxx/w1_slave */
    snprintf(ds_path, sizeof(ds_path), "%s/%s/w1_slave", w1_path, chip);

    /* 接下来打开 DS18B20 的采样文件 */
    if( (fd=open(ds_path, O_RDONLY)) < 0 )
    {
        printf("open %s error: %s\n", ds_path, strerror(errno));
        return -4;
    }

    /* 读取文件中的内容将会触发 DS18B20温度传感器采样,这里读取文件内容保存到buf中 */
    if(read(fd, buf, sizeof(buf)) < 0)
    {
        printf("read %s error: %s\n", ds_path, strerror(errno));

        /* 1, 这里不能直接调用 return直接返回,否则的话前面open()打开的文件描述符就没有关闭。
         * 这里设置 rv 为错误码-5,通过 goto 语句跳转到函数后面统一进行错误处理。
         *
         * 2, 在C语言编程时我们应该慎用goto语句进行"随意"的跳转,因为它会降低代码的可读性。但这里是
         * goto语句的一个非常典型应用,我们经常会用它来对错误进行统一的处理。
         */
        rv = -5;
        goto cleanup;
    }

    /* 采样温度值是在字符串"t="后面,这里我们从buf中找到"t="字符串的位置并保存到ptr指针中 */
    ptr = strstr(buf, "t=");
    if( !ptr )
    {
        printf("ERROR: Can not get temperature\n");
        rv = -6;
        goto cleanup;
    }

    /* 因为此时ptr是指向 "t="字符串的地址(即't'的地址),那跳过2个字节(t=)后面的就是采样温度值 */
    ptr+=2;

    /* 接下来我们使用 atof() 函数将采样温度值字符串形式,转化成 float 类型。*/
    *temp = atof(ptr)/1000;

    /* 1,在这里我们对函数返回进行集中处理,其中 cleanup 为 goto 语句的标号;
     * 2,在函数退出时,我们应该考虑清楚在前面的代码中做了哪些事,这些事是否需要进行反向操作。如
     *    打开的文件或文件夹是否需要关闭,malloc()分配的内存是否需要free()等。
     * 3, 在最开始我们定义rv并赋初值为0(表示成功)是有原因的,如果前面的代码任何一个地方出现错误,
     *    则会将rv的值修改为相应的错误码,否则rv的值将始终为0(即没有错误发生),这里将统一返回。
     */
cleanup:
    close(fd);
    return rv;
}

2.4.3.4 交叉编译测试运行

程序编写好之后,接下来我们再次修改 makefile 文件,添加 ds18b20 程序的编译支持。

guowenxue@ubuntu20:~/igkboard/apps$ vim makefile 
... ...
all:
        ... ...
        ${CC} ds18b20.c -o ds18b20

clean:
        ... ...
        @rm -f ds18b20

然后使用 make 命令来交叉编译程序。

guowenxue@ubuntu20:~/igkboard/apps$ make

现在我们在开发板上通过scp 命令 或其它方式将编译生成的测试程序下载到开发板上。

root@igkboard:~# scp -P 2288 guowenxue@192.168.2.2:~/igkboard/apps/ds18b20 .
guowenxue@192.168.2.2's password: 
ds18b20                                         100% 8752   794.3KB/s   00:00 

接下来,我们给该程序加上执行权限并运行。运行结果显示当前的温度值为 24.875℃。

root@igkboard:~# chmod a+x ds18b20 

root@igkboard:~# ./ds18b20 
DS18B20 get temperature: 24.875000 'C

如果,我们的开发板上没有连接 DS18B20 温度传感器,则程序的运行结果为:

root@igkboard:~# ./ds18b20 
Can not find ds18b20 in /sys/bus/w1/devices/
ERROR: ds18b20 get temprature failure

2.5 PWM编程控制

版权声明

本文档所有内容文字资料由凌云实验室郭工编著,主要用于凌云嵌入式Linux教学内部使用,版权归属作者个人所有。任何媒体、网站、或个人未经本人协议授权不得转载、链接、转帖或以其他方式复制发布/发表。已经授权的媒体、网站,在下载使用时必须注明来源,违者本人将依法追究责任。

  • Copyright (C) 2022 凌云物网智科实验室·郭工

  • Author: Guo Wenxue Email: guowenxue@gmail.com QQ: 281143292

wechat_pub

2.5.1 PWM介绍

PWM(Pulse Width Modulation),是脉冲宽度调制缩写,它是通过对一系列脉冲的宽度进行调制,等效出所需要的波形(包含形状以及幅值),对模拟信号电平进行数字编码,也就是说通过调节占空比的变化来调节信号、能量等的变化,占空比就是指在一个周期内,信号处于高电平的时间占据整个信号周期的百分比,例如方波的占空比就是50%。PWM是利用微处理器的数字输出来对模拟电路进行控制的一种非常有效的技术。PWM信号一般下图所示

PWM_example_1

PWM常见的应用场合

经常见到的就是交流调光电路(手机充电的呼吸灯),也可以说是无级调速,高电平占多一点,也就是占空比大一点亮度就亮一点,占空比小一点亮度就没有那么亮,前提是PWM的频率要大于我们人眼识别频率(大约80Hz以上最好)。在电机驱动、无源蜂鸣器驱动、LCD屏幕背光调节、逆变电路等都会有应用.

PWM的参数说明

  • PWM的频率

是指1秒钟内信号从高电平到低电平再回到高电平的次数(一个周期),即一秒钟PWM有多少个周期。 单位: Hz 表示方式: 50Hz 100Hz

  • PWM的周期

周期=1/频率 50Hz = 1/50 s = 0.02s = 20ms ,如果频率为50Hz ,也就是说一个周期是20ms 那么一秒钟就有 50次PWM周期。

  • 占空比

是一个脉冲周期内,高电平的时间与整个周期时间的比例 单位: % (0%-100%) 表示方式:20%

使用下面示例图进行参考,知晓各个参数在实际PWM信号中表达的含义

PWM_example_2

周期: 一个脉冲信号的时间 1s内测周期次数等于频率

脉宽时间: 高电平时间

脉宽时间占总周期时间的比例,即占空比

2.5.2 PWM控制命令

在我们的IGKBoard-IMX6ULL开发板上使用了4路 PWM控制器,其中:

  • PWM1 <——> backlight(LCD显示屏背光)

  • PWM2 <——> beep(蜂鸣器)

  • PWM7,PWM8 <——> 40pin扩展(需要配置开启)

PWM 同样也是通过 sysfs 方式进行操控,进入到/sys/class/pwm 目录下,默认只可以看到 2 个以pwmchipX(X表示数字0~1)命名的文件夹,这是由于 PWM7 和 PWM8 两路 PWM默认没有开启。

root@igkboard:~# ls /sys/class/pwm/
pwmchip0  pwmchip1

它的对应关系是:

  • pwmchip0 —> PWM1 —> backlight

  • pwmchip1 —> PWM2 —> beep

由于 PWM1 被LCD背光驱动占用在用户空间不可以使用,这里以连接蜂鸣器的 PWM2 为例讲解 Linux下的PWM使用方法。

首先看看蜂鸣器对应控制器 pwmchip1 下的相关文件。

root@igkboard:~# ls /sys/class/pwm/pwmchip1/
device  export  npwm  power  subsystem  uevent  unexport

这里只需要重点关注三个属性文件,export(只写)npwm(只读) 以及 unexport(只写)

  • npwm:这是一个只读属性,读取该文件可以得知该PWM控制器下共有几路PWM输出,如下所示:

root@igkboard:~# cat /sys/class/pwm/pwmchip1/npwm 
1
  • export:与GPIO控制一样,在使用PWM之前,也需要将其导出,通过export属性进行导出,如下所示:

root@igkboard:~# echo 0 > /sys/class/pwm/pwmchip1/export
root@igkboard:~# ls /sys/class/pwm/pwmchip1/
device  export  npwm  power  pwm0  subsystem  uevent  unexport

上面写入的 0 表示该PWM控制器的第一个Channel,导出之后 pwmchip1 文件夹下将会生成一个名为 pwm0 的文件,稍后介绍。

  • unexport:当使用完PWM之后,我们需要将导出的PWM删除,如下所示

root@igkboard:~# echo 0 > /sys/class/pwm/pwmchip1/unexport 
root@igkboard:~# ls /sys/class/pwm/pwmchip1/               
device  export  npwm  power  subsystem  uevent  unexport

将相应的 PWM Channel 编号写入到 unexport 文件中,将会取消该 Channel 的导出。此时该路径下 export 生成的文件也将会消失;需要注意的是,export文件和unexport文件都是只写的、没有读权限。

接下来我们导出蜂鸣器相应的PWM,并看看它里面的文件。

root@igkboard:~# echo 0 > /sys/class/pwm/pwmchip1/export 

root@igkboard:~# ls /sys/class/pwm/pwmchip1/pwm0/
capture  duty_cycle  enable  period  polarity  power  uevent

该目录下也有一些属性文件,我们重点关注duty_cycle、enable、period以及polarity这四个属性文件,接下来一一进行介绍。

  • enable:可读可写,写入”0”表示禁止PWM;写入”1”表示使能PWM。读取该文件获取PWM当前是禁止还是使能状态。

echo 0 > enable #禁止PWM输出 
echo 1 > enable #使能PWM输出
  • polarity:用于设置极性,可读可写,可写入的值如下。

echo normal > polarity #默认极性 
echo inversed > polarity #极性反转

很多SoC的PWM外设其硬件上并不支持极性配置,所以对应的驱动程序中并未实现这个接口,应用层自然也就无法通过polarity属性文件对PWM极性进行配置,IGKBoard开发板系统便是如此。

  • period:用于配置PWM周期,可读可写;写入一个字符串数字值,以**ns(纳秒)**为单位,譬如配置PWM周期为10us(微秒)。

echo 10000 > period #PWM周期设置为10us(10 * 1000ns)
  • duty_cycle:用于配置PWM的占空比,可读可写;写入一个字符串数字值,同样也是以ns为单位.

echo 5000 > duty_cycle #PWM占空比设置为5us

我们知道人耳能听到的频率范围在20Hz–20kHz之间,这里我们将连接蜂鸣器的 PWM 输出一个 10KHz、占空比为50% 的方波,应该就可以听到蜂鸣器的声音了。

在前面提到的文件中,我们没有设置频率的文件,而有周期的文件 period。我们知道频率单位Hz的意思是 1秒钟做某件事的次数,他与周期的关系是 F=1/T。所以 10KHz 对应的周期是:

1/F = 1s/10K = 1000 000 000ns/10 000 = 100 000

接下来我们就可以使用下面命令控制蜂鸣器发声了。

root@igkboard:~# echo 100000 > /sys/class/pwm/pwmchip1/pwm0/period   
root@igkboard:~# echo 50000 > /sys/class/pwm/pwmchip1/pwm0/duty_cycle 
root@igkboard:~# echo 1 > /sys/class/pwm/pwmchip1/pwm0/enable 
root@igkboard:~# echo 0 > /sys/class/pwm/pwmchip1/pwm0/enable 

另外,我们也可以调整 PWM 的频率来控制蜂鸣器的音色。

root@igkboard:~# echo 200000 > /sys/class/pwm/pwmchip1/pwm0/period 
root@igkboard:~# echo 100000 > /sys/class/pwm/pwmchip1/pwm0/duty_cycle 
root@igkboard:~# echo 1 > /sys/class/pwm/pwmchip1/pwm0/enable         
root@igkboard:~# echo 0 > /sys/class/pwm/pwmchip1/pwm0/enable  

2.5.3 PWM编程控制

2.5.3.1 PWM应用编程

编写 PWM 测试程序的源代码 pwm.c 如下。

guowenxue@ubuntu20:~/igkboard/apps$ vim pwm_test.c
/*********************************************************************************
 *      Copyright:  (C) 2021 Guo Wenxue<Email:guowenxue@gmail.com QQ:281143292>
 *                  All rights reserved.
 *
 *       Filename:  pwm_test.c
 *    Description:  This file used to test PWM 
 *                 
 *        Version:  1.0.0(10/1/2022~)
 *         Author:  Guo Wenxue <guowenxue@gmail.com>
 *      ChangeLog:  1, Release initial version on "10/1/2022 17:46:18 PM"
 *                 
 ********************************************************************************/
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>

static char pwm_path[100];

/*pwm 配置函数 attr:属性文件名字 
 *  val:属性的值(字符串)
*/
static int pwm_config(const char *attr, const char *val)
{
    char file_path[100];
    int len;
    int fd =  -1;

    if(attr == NULL || val == NULL)
    {
        printf("[%s] argument error\n", __FUNCTION__);
        return -1;
    }

    memset(file_path, 0, sizeof(file_path));
    snprintf(file_path, sizeof(file_path), "%s/%s", pwm_path, attr);
    if (0 > (fd = open(file_path, O_WRONLY))) {
        printf("[%s] open %s error\n", __FUNCTION__, file_path);
        return fd;
    }

    len = strlen(val);
    if (len != write(fd, val, len)) {
        printf("[%s] write %s to %s error\n", __FUNCTION__, val, file_path);
        close(fd);
        return -2;
    }

    close(fd);  //关闭文件
    return 0;
}

int main(int argc, char *argv[])
{
    char temp[100];
    int fd = -1;

    /* 校验传参 */
    if (4 != argc) {
        printf("usage: %s <id> <period> <duty>\n", argv[0]);
        exit(-1);  /* exit(0) 表示进程正常退出 exit(非0)表示异常退出*/
    }

    /* 打印配置信息 */
    printf("PWM config: id<%s>, period<%s>, duty<%s>\n", argv[1], argv[2], argv[3]);

    /* 导出pwm 首先确定最终导出的文件夹路径*/
    memset(pwm_path, 0, sizeof(pwm_path));
    snprintf(pwm_path, sizeof(pwm_path), "/sys/class/pwm/pwmchip%s/pwm0", argv[1]);
    
    //如果pwm0目录不存在, 则导出
    memset(temp, 0, sizeof(temp));
    if (access(pwm_path, F_OK)) {
        snprintf(temp, sizeof(temp) , "/sys/class/pwm/pwmchip%s/export", argv[1]);
        if (0 > (fd = open(temp, O_WRONLY))) {
            printf("open pwmchip%s error\n", argv[1]);
            exit(-1);
        }
        //导出pwm0文件夹
        if (1 != write(fd, "0", 1)) {
            printf("write '0' to  pwmchip%s/export error\n", argv[1]);
            close(fd);
            exit(-2);
        }

        close(fd); 
    }

    /* 配置PWM周期 */
    if (pwm_config("period", argv[2]))
        exit(-1);

    /* 配置占空比 */
    if (pwm_config("duty_cycle", argv[3]))
        exit(-1);

    /* 使能pwm */
    pwm_config("enable", "1");

    return 0;
}

2.5.3.2 PWM交叉编译

程序编写好之后,接下来我们再次修改 makefile 文件,添加 pwm 程序的编译支持。

guowenxue@ubuntu20:~/igkboard/apps$ vim makefile 
... ...
all:
        ... ...
        ${CC} pwm.c -o pwm

clean:
        ... ...
        @rm -f pwm

然后使用 make 命令来交叉编译程序。

guowenxue@ubuntu20:~/igkboard/apps$ make

现在我们在开发板上通过scp 命令 或其它方式将编译生成的测试程序下载到开发板上。

root@igkboard:~# scp -P 2288 guowenxue@192.168.2.2:~/igkboard/apps/pwm .
guowenxue@192.168.2.2's password: 
pwm                                         100%   14KB   1.1MB/s   00:00

接下来,我们给该程序加上执行权限并运行,如果没有指定参数则会给出提示信息。

root@igkboard:~# chmod a+x pwm 

root@igkboard:~# ./pwm 
Usage: pwm [OPTION]...
 This is pwm control program. 
 -p[pwm     ]  Specify PWM chip, such as 1 for pwmchip1
 -c[channel ]  Specify PWM channel, such as 0
 -f[freq    ]  Specify PWM frequency, default 2500(Hz)
 -d[duty    ]  Specify PWM duty, default 50(50%)
 -s[status  ]  Specify PWM status: 1->on(default), 0->off
 -h[help    ]  Display this help information
 -v[version ]  Display the program version

Example buzzer : pwm -p 1 -c 0 -f 10000 -d 50 -s 1
Example Led    : pwm -p 1 -c 1 -f 100 -d 50 -s 1
Example disable: pwm -p 1 -c 0 -s 0

pwm program Version v1.0.0
Copyright (C) 2023 LingYun IoT System Studio.

使用下面命令可以开启/关闭蜂鸣器。需要注意的是蜂鸣器开启后,会产生较大噪声,注意周围环境,以免打扰别人。

root@igkboard:~# ./pwm -p 1 -c 0 -f 10000 -d 50 -s 1
config pwm1 channel0 with freq[10000] duty[50] okay
pwm[1] channel[0]enable

root@igkboard:~# ./pwm -p 1 -c 0 -s 0 
pwm[1] channel[0]disable

在上面的命令行选项中:

  • -p 1 用来指定使用 pwmchip0 ,即 /sys/class/pwm/pwmchip1

  • -c 0 用来指定导出 Channel pwm0,即 /sys/class/pwm/pwmchip1/pwm0

  • -f 10000 用来指定PWM的工作频率为 10KHz;

  • -d 50 用来指定占空比为 50%;

  • -s 0/1 用来指定 PWM 的 status: 1为 enable, 0 为 disable;

2.5.3.4 Led呼吸灯测试

在开发板上执行 pinctrl -v 命令,我们可以看到物理引脚 #28 和 #32 分别可以作为 PWM8 和 PWM7 使用。

pinctrl_pwm

前面我们提到,40Pin 上的这两路 PWM 默认并没有使能,这里我们需要修改 eMMC/TF卡 启动介质的 boot 分区下的 config.txt 文件来使能他们。具体方法为修改 dtoverlay_pwm 选项中添加 pwm7、pwm8 支持即可。

root@igkboard:~# vi /run/media/mmcblk1p1/config.txt 

# Enable PWM overlays, PWM8 conflict with UART8(NB-IoT/4G module)
dtoverlay_pwm=7 8

root@igkboard:~# sync

如果系统默认没有挂载这个分区,则需要先使用 mount 命令来手动挂载。

root@igkboard:~# mount | grep mmcblk
/dev/mmcblk1p2 on / type ext4 (rw,relatime)

root@igkboard:~# mkdir -p /run/media/mmcblk1p1/
root@igkboard:~# mount /dev/mmcblk1p1 /run/media/mmcblk1p1/

使用 reboot 重启后,我们可以看到 /sys/class/pwm/ 会出现 4 个 pwm 设备。

root@igkboard:~# ls /sys/class/pwm/
pwmchip0  pwmchip1  pwmchip2  pwmchip3

接下来将开发板断电,并将将RGB三色 Led 的任何两个灯分别连到 32# 和 28# 引脚上,阴极连接GND。如下图所示连接。

igkboard_pwm_led

使用下面命令可以通过控制其占空比就可以控制 PWM7 相应Led灯的亮灭程度。

root@igkboard:~# ./pwm -p 2 -c 0 -f 100 -d 100 -s 1
root@igkboard:~# ./pwm -p 2 -c 0 -f 100 -d 75 -s 1  
root@igkboard:~# ./pwm -p 2 -c 0 -f 100 -d 50 -s 1 
root@igkboard:~# ./pwm -p 2 -c 0 -f 100 -d 25 -s 1
root@igkboard:~# ./pwm -p 2 -c 0 -f 100 -d 1 -s 1  

使用下面命令可以通过控制其占空比就可以控制 PWM8 相应Led灯的亮灭程度。

root@igkboard:~# ./pwm -p 3 -c 0 -f 100 -d 100 -s 1
root@igkboard:~# ./pwm -p 3 -c 0 -f 100 -d 75 -s 1  
root@igkboard:~# ./pwm -p 3 -c 0 -f 100 -d 50 -s 1 
root@igkboard:~# ./pwm -p 3 -c 0 -f 100 -d 25 -s 1
root@igkboard:~# ./pwm -p 3 -c 0 -f 100 -d 1 -s 1  

这样如果我们有三路 PWM 可以分别控制三个 RGB Led灯的话,通过调整频率、占空比就能调整出他们组合出来的各种组合色彩,另外通过亮度的延时控制,也可以实现呼吸灯的效果。

下面是使用 Shell 脚本来实现呼吸灯的效果。

root@igkboard:~# vim heartbeat.sh 
#!/bin/bash

pwm=2
freq=100

# duty to control the led brightness
duty=100

# increase or decrease
add=0

# increase or decrease step
step=5

# timeout value
timeout=0.2

while [[ 1 == 1 ]] ; do

   if [ $duty -ge 100 ]  ;then
        add=0
   elif [ $duty -le $step ]  ;then
        add=1
   fi

   ./pwm -p $pwm -c 0 -f $freq -d $duty -s 1 > /dev/null 2>&1

   if [[ $add == 1 ]] ; then
        let duty=`expr $duty + $step`
   else
        let duty=`expr $duty - $step`
   fi

   sleep $timeout

done

执行该Shell脚本程序我们会看到 Led 会从亮变暗,然后又从暗变亮。

root@igkboard:~# chmod a+x heartbeat.sh 
root@igkboard:~# ./heartbeat.sh 

2.5.3.5 蜂鸣器播放音乐

关于音调基础乐理知识

简单的物理学知识告诉我们,声音的声调高低是有声波的频率决定的,而声音的大小是有声波的振幅决定的。人耳可以听到的声音频率在 20Hz-20000Hz 之间。频率低于 20Hz 的声波被称为次声波,我们听不到。同样,频率高于 20000Hz 的声波称为超声波,我们也听不到。生活中,我们都喜欢听美妙的音乐,音乐是由很多不同的音调和参差的拍子组合而成的。小时候音乐课上常说的 Do,Re,Mi,Fa,So,La,Xi 就是最常见的音调。我们以 C 大调为例,其 7 个音阶与对应的频率如下表所示:

Do

Re

Mi

Fa

So

La

Xi

262

294

330

350

393

441

495

音乐国标频率中,A4是440Hz的频率,C4(钢琴的中央C)是261.626Hz,就是说使用PWM的freq=262即可得到“哆”音。

pwm_note

要演奏出美妙的旋律,仅仅只控制音调是不够的,我们还需要把握每个音调演奏的节奏,即每个音调播放的时长。在乐曲中,节奏的把控是通过节拍来定义的,例如常见的四分之四节拍,指的是四分音符为一拍,每小节有四拍,这样,每遇到一个四分音符我们就播放这个音调一拍的单位时间,如果遇到八分音符,我们就播放二分之一拍的单位时间,如果遇到二分音符,我们就播放两拍的单位时间。

下面是从网上找的《小星星》简谱,因为里面没有连音什么的,咱们简单处理就成了,让每拍占秒时间,拍与拍之间都要有个小停顿。

pwm_stars

使用 pwm 蜂鸣器播放《小星星》的源代码如下:

guowenxue@ubuntu20:~/igkboard/apps$ vim pwm_play.c 
/*********************************************************************************
 *      Copyright:  (C) 2021 Guo Wenxue<Email:guowenxue@gmail.com QQ:281143292>
 *                  All rights reserved.
 *
 *       Filename:  pwm_music.c
 *    Description:  This file used to test PWM music
 *                 
 *        Version:  1.0.0(10/21/2022~)
 *         Author:  Guo Wenxue <guowenxue@gmail.com>
 *      ChangeLog:  1, Release initial version on "10/21/2022 17:46:18 PM"
 *                 
 ********************************************************************************/
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <time.h>
#include <unistd.h>
#include <string.h>

#define PWM_CHIP                        1 /* buzzer on pwmchip1 */

typedef struct pwm_note_s
{
    unsigned int    msec; //持续时间,毫秒
    unsigned int    freq;//频率
    unsigned char   duty;//相对占空比,百分比 * 100
}pwm_note_t;

/* 使用宏确定使用中音还是高、低音 */
#define CX                      CM

/* 低、中、高音频率*/
static const unsigned short CL[8] = {0, 262, 294, 330, 349, 392, 440, 494};
static const unsigned short CM[8] = {0, 523, 587, 659, 698, 784, 880, 988};
static const unsigned short CH[8] = {0, 1046, 1175, 1318, 1397, 1568, 1760, 1976};

/* 小星星曲子*/
static unsigned short melody[] = {
    CX[1], CX[1], CX[5], CX[5], CX[6], CX[6], CX[5], CX[0],
    CX[4], CX[4], CX[3], CX[3], CX[2], CX[2], CX[1], CX[0],
    CX[5], CX[5], CX[4], CX[4], CX[3], CX[3], CX[2], CX[0],
    CX[5], CX[5], CX[4], CX[4], CX[3], CX[3], CX[2], CX[0],
};

static char pwm_path[64]; /* pwm file path buffer */

static int pwm_export(int chip);
static int pwm_config(const char *attr, const char *val);
static int pwm_ring(pwm_note_t *note);
static inline void msleep(unsigned long ms);

int main(int argc, char *argv[])
{
    pwm_note_t    note;
    int           i;

        if( pwm_export(PWM_CHIP) < 0 ) 
                return 1;

    pwm_config("enable", "1");

    for(i=0; i<sizeof(melody)/sizeof(melody[0]); i++)
    {
        if(melody[i] == 0)
        {
            note.duty = 0; 
        }
        else
        {
            note.duty = 15; //越大音量越大
            note.freq = melody[i];
        }
        note.msec = 300;

        pwm_ring(&note);
    }

    pwm_config("enable", "0");

    return 0;
}

static int pwm_export(int chip)
{
    char file_path[100];
    int  fd, rv=0;

    /* 导出pwm 首先确定最终导出的文件夹路径*/
    memset(pwm_path, 0, sizeof(pwm_path));
    snprintf(pwm_path, sizeof(pwm_path), "/sys/class/pwm/pwmchip%d/pwm0", PWM_CHIP);
    
    //如果pwm0 目录已经存在了, 则直接返回
    if ( !access(pwm_path, F_OK)) 
                return 0;

        //如果pwm0 目录不存在, 则开始导出

        memset(file_path, 0, sizeof(file_path));
        snprintf(file_path, sizeof(file_path) , "/sys/class/pwm/pwmchip%d/export", PWM_CHIP);

        if ( (fd = open(file_path, O_WRONLY) < 0))
        {
                printf("ERROR: open pwmchip%d error\n", PWM_CHIP);
                return 1;
        }

        if ( write(fd, "0", 1) < 0 )
        {
                printf("write '0' to  pwmchip%d/export error\n", PWM_CHIP);
                rv = 2;
        }

        close(fd); 
        return rv;
}

/*pwm 配置函数 attr:属性文件名字 
 *  val:属性的值(字符串)
*/
static int pwm_config(const char *attr, const char *val)
{
    char file_path[100];
    int fd;

    if( !attr || !val )
    {
        printf("[%s] argument error\n", __FUNCTION__);
        return -1;
    }

    memset(file_path, 0, sizeof(file_path));
    snprintf(file_path, sizeof(file_path), "%s/%s", pwm_path, attr);
    if( (fd=open(file_path, O_WRONLY)) < 0 ) 
        {
        printf("[%s] open %s error\n", __FUNCTION__, file_path);
        return fd;
    }

    if ( write(fd, val, strlen(val)) < 0) {
        printf("[%s] write %s to %s error\n", __FUNCTION__, val, file_path);
        close(fd);
        return -2;
    }

    close(fd);  //关闭文件
    return 0;
}

/* pwm蜂鸣器响一次声音 */
static int pwm_ring(pwm_note_t *note)
{
    unsigned long period = 0;
    unsigned long duty_cycle = 0;
    char period_str[20] = {};
    char duty_cycle_str[20] = {}; 

    if( !note || note->duty > 100 )
    {
        printf("[INFO] %s argument error.\n", __FUNCTION__);
        return -1;
    }

    period = (unsigned long)((1.f / (double)note->freq) * 1e9);//ns单位
    duty_cycle = (unsigned long)(((double)note->duty / 100.f) * (double)period);//ns单位

    snprintf(period_str, sizeof(period_str), "%lu", period);
    snprintf(duty_cycle_str, sizeof(duty_cycle_str), "%lu", duty_cycle);

    //设置pwm频率和周期
    if (pwm_config("period", period_str))
    {
        printf("pwm_config period failure.\n");
        return -1;
    }
    if (pwm_config("duty_cycle", duty_cycle_str))
    {
        printf("pwm_config duty_cycle failure.\n");
        return -2;
    }

    msleep(note->msec);

    /* 设置占空比为0 蜂鸣器无声 */
    if (pwm_config("duty_cycle", "0"))
    {
        printf("pwm_config duty_cycle failure.\n");
        return -3;
    }
    msleep(20);

    return 0;
}

/* ms级休眠函数 */
static inline void msleep(unsigned long ms)
{
    struct timespec cSleep;
    unsigned long ulTmp;

    cSleep.tv_sec = ms / 1000;
    if (cSleep.tv_sec == 0)
    {
        ulTmp = ms * 10000;
        cSleep.tv_nsec = ulTmp * 100;
    }
    else
    {
        cSleep.tv_nsec = 0;
    }

    nanosleep(&cSleep, 0);
}

程序编写好之后,接下来我们再次修改 makefile 文件,添加 pwm 程序的编译支持。

guowenxue@ubuntu20:~/igkboard/apps$ vim makefile 
... ...
all:
        ... ...
        ${CC} pwm_play.c -o pwm_play

clean:
        ... ...
        @rm -f pwm_play

然后使用 make 命令来交叉编译程序。

guowenxue@ubuntu20:~/igkboard/apps$ make

现在我们在开发板上通过scp 命令 或其它方式将编译生成的测试程序下载到开发板上。

root@igkboard:~# scp -P 2288 guowenxue@192.168.2.2:~/igkboard/apps/pwm_play .
guowenxue@192.168.2.2's password: 
pwm                                         100% 9032   774.3KB/s   00:00

接下来,我们给该程序加上执行权限并运行,这时就可以听到板载蜂鸣器播放小星星曲子了。

root@igkboard:~# chmod a+x pwm_play 
root@igkboard:~# ./pwm_play  

2.6 ADC编程烟雾传感器采样

版权声明

本文档所有内容文字资料由凌云实验室郭工编著,主要用于凌云嵌入式Linux教学内部使用,版权归属作者个人所有。任何媒体、网站、或个人未经本人协议授权不得转载、链接、转帖或以其他方式复制发布/发表。已经授权的媒体、网站,在下载使用时必须注明来源,违者本人将依法追究责任。

  • Copyright (C) 2022 凌云物网智科实验室·郭工

  • Author: Guo Wenxue Email: guowenxue@gmail.com QQ: 281143292

wechat_pub

2.6.1 ADC简介

ADC Analog to Digital Converter的缩写,中文名称模数转换器。它可以将外部的模拟信号转化成数字信号。对于 GPIO口来说高于某个电压值,它读出来的只有高电平,低于就是低电平。假如我想知道具体的电压数值就要借助于 ADC的帮助,它可以将一个范围内的电压精确的读取出来。假设我们的 GPIO 口只要高于 1.7V的都认为是高电平,例如,比如某个 IO口上外接了一个设备它能提供 0-2V的电压变化,我们在这个 IO口上使用 GPIO模式去读取的话我们只能获得0和1两个数据,但是我们使用ADC模式去读取就可以获得 0-2V之间连续变化的数值。

ADC有几个比较重要的参数:

  • 测量范围

    测量范围对于 ADC来说就好比尺子的量程, ADC测量范围决定了你外接的设备其信号输出电压范围,不能超过 ADC的测量范围。如果所使用的外部传感器输出的电压信号范围和所使用的 ADC测量范围不符 合,那么就需要自行设计相关电压转换电路。

  • 分辨率

    就是尺子上的能量出来的最小测量刻度,例如我们常用的厘米尺它的最小刻度就是 1毫米,表示最小测量精度就是 1毫米。假如 ADC的测量范围为 0-5V,分辨率设置为 12位,那么我们能测出来的最小电压就是 5V除以 2的 12次方,也就是 5/4096=0.00122V。很明显,分辨率越高,采集到的信号越精确,所以分辨率是衡量 ADC的一个重要指标。

  • 精度 是影响结果准确度的因素之一,比如在厘米尺上我们能测量出大概多少毫米的尺度但是毫米后一点点我们却不能准确的量出。经过计算我们 ADC在 12位分辨率下的最小测量值是 0.00122V但是我们 ADC的精度最高只能到 11位也就是 0.00244V。也就是 ADC测量出0.00244V的结果是要比 0.00122V要可靠,也更准确。

  • 采样时间 当 ADC在某时刻采集外部电压信号的时候,此时外部的信号应该保持不变,但实际上外部的信号是不停变化的。所以在 ADC内部有一个保持电路,保持某一时刻的外部信号,这样 ADC就可以稳定采集了,保持这个信号的时间就是采样时间。

  • 采样率 也就是 在一秒的时间内采集多少次。很明显,采样率越高越好,当采样率不够的时候可能会丢失部 分信息,所以 ADC采样率是衡量 ADC性能的另一个重要指标。

    总之,只要是需要模拟信号转为数字信号的场合,那么肯定要用到ADC。很多数字传感器内部会集成 ADC,传感器内部使用 ADC来处理原始的模拟信号,最终给用户输出数字信号。

2.6.2 MQ-2烟雾传感器简介

MQ-2常用于家庭和工厂的气体泄漏监测装置,适宜于液化气、苯、烷、酒精、氢气、烟雾等的探测。故因此,MQ-2可以准确来说是一个多种气体探测器。MQ-2的探测范围极其的广泛。它的优点:灵敏度高、响应快、稳定性好、寿命长、驱动电路简单。一般MQ-2模块如下图所示:

ADC_MQ-2

2.6.2.1 MQ-2工作原理与特性

MQ-2型烟雾传感器属于二氧化锡半导体气敏材料,属于表面离子式N型半导体。处于200~300摄氏度时,二氧化锡吸附空气中的氧,形成氧的负离子吸附,使半导体中的电子密度减少,从而使其电阻值增加。当与烟雾接触时,如果晶粒间界处的势垒收到烟雾的浓度而变化,就会引起表面导电率的变化。利用这一点就可以获得这种烟雾存在的信息,烟雾的浓度越大,导电率越大,输出电阻越低,则输出的模拟信号就越大

  • MQ-2型传感器对天然气、液化石油气等烟雾有很高的灵敏度,尤其对烷类烟雾更为敏感,具有良好的抗干扰性,可准确排除有刺激性非可燃性烟雾的干扰信息。

  • MQ-2型传感器具有良好的重复性和长期的稳定性。初始稳定,响应时间短,长时间工作性能好。需要注意的是:在使用之前必须加热一段时间,否则其输出的电阻和电压不准确。

  • 其检测可燃气体与烟雾的范围是100~10000ppm(ppm为体积浓度。1ppm=1立方厘米/1立方米)

  • 电路设计电压范围宽,24V以下均可,加热电压5±0.2V

注意:如果加热电压过高,会导致输入电流过大,将内部的信号线熔断,从而器件报废。

2.6.2.1 MQ-2应用电路

MQ-2常用的电路有两种,一种使用采用比较器电路监控,另一种为ADC电路检测。

以下图中的模块仅有比较器电路,ADC部分使用IGKBoard的主控集成的ADC进行检测,下文进行详细介绍和使用。

ADC_MQ-2_circuit

MQ-2的引脚4输出随烟雾浓度变化的直流信号,被加到比较器U1A的2脚,Rp构成比较器的门槛电压。当烟雾浓度较高输出电压高于门槛电压时,比较器输出低电平(0v),此时LED亮报警;当浓度降低传感器的输出电压低于门槛电压时,比较器翻转输出高电平(Vcc),LED熄灭。调节Rp,可以调节比较器的门槛电压,从而调节报警输出的灵敏度。R1串入传感器的加热回路,可以保护加热丝免受冷上电时的冲击。

对于ADC电路而言,只需使用杜邦线将AOUT连接至ADC模拟输入端即可。

2.6.3 开发板ADC检测实验

2.6.3.1 模块硬件连接说明

MQ-2传感器的工作电压在24V以下即可,建议使用板载5V进行供电。这样在ADC电路设计时候,MQ-2在与开发板相连时候,主要连接如下三个引脚:

  1. GND,该引脚要连到开发板的GND扩展引脚上;

  2. VCC, 该引脚要连到开发板的 5v 供电引脚上;

  3. AOUT, 是MQ-2模块的模拟输出引脚,该引脚应该连开发板上ADC功能的GPIO引脚上;

在IGKBoard开发板上,提供了两个ADC模拟输入的引脚,位于开发板的8pin扩展口上,其管脚名为TS_XP和TS_YN,在Linux系统启动时如果启用了 ADC overlay 后,它俩会默认作为ADC模拟输入口使用。这样,MQ-2的AO引脚应该连接上其中之一即可。

ADC_8pin_hat

如下是MQ-2烟雾传感器连接到IGKBoard上的实物示意图。

ADC_igkboard_MQ-2_connect

实物连接图如下。

ADC_igkboard_MQ-2_Real_connect

2.6.3.2 驱动配置使用说明

在前面,我们将MQ-2的AOUT引脚连到了IGKBoard开发板扩展接口的TS_YN引脚上(GPIO01_IO01),该引脚在系统启动时有可能 默认作为GPIO功能使用,另外它还与LCD上的触摸屏冲突。如果想作为MQ-2的模拟信号采样引脚使用的话,我们需要修改开发板上的DTOverlay配置文件,添加该引脚的 ADC 支持。

具体方法为修改 eMMC/TF卡 启动介质的 boot 分区下的 config.txt 文件,修改 dtoverlay_adc 选项中添加 adc 支持即可。

root@igkboard:~# vim /run/media/mmcblk1p1/config.txt 

# Enable ADC overlay
dtoverlay_adc=yes

如果系统默认没有挂载这个分区,则需要先使用 mount 命令来手动挂载。

root@igkboard:~# mount | grep mmcblk
/dev/mmcblk1p2 on / type ext4 (rw,relatime)

root@igkboard:~# mkdir -p /run/media/mmcblk1p1/
root@igkboard:~# mount /dev/mmcblk1p1 /run/media/mmcblk1p1/

修改完成后重启系统,系统启动时将会自动加载IGKBoard的芯片内置ADC驱动。查看驱动加载是否成功,我们可以在**/sys/bus/iio/devices**目录下查看ADC对应的iio设备:iio:deviceX

root@igkboard:/sys/bus/iio/devices# ls
iio:device0

我们使用 ls 命令看看,这个文件夹下有哪些文件。

root@igkboard:/sys/bus/iio/devices/iio:device0# ls
buffer   dev                 in_voltage0_raw  in_voltage2_raw  in_voltage4_raw                in_voltage_scale  of_node  sampling_frequency_available  subsystem  uevent
buffer0  in_conversion_mode  in_voltage1_raw  in_voltage3_raw  in_voltage_sampling_frequency  name              power    scan_elements                 trigger

在这里,只用关心三个文件:

in_voltage1_raw :ADC1通道 1原始值文件,即**TS_YN(GPIO01_IO01)**管脚的输入模拟值转换的数字值,范围0-4095;

in_voltage4_rawADC1通道 4原始值文件,即**TS_XP(GPIO01_IO04)**管脚的输入模拟值转换的数字值,范围0-4095;

in_voltage_scaleADC1比例文件 (分辨率 ),单位为 mV。实际电压值 (mV) = in_voltage1_raw * in_voltage_scale

我们开发板当前的in_voltage1_raw和 in_voltage_scale这两个文件内容如下:

root@igkboard:/sys/bus/iio/devices/iio:device0# cat in_voltage1_raw 
1002
root@igkboard:/sys/bus/iio/devices/iio:device0# cat in_voltage_scale 
0.805664062

经计算,当前TS_YN管脚上的实际电压是 1002 * 0.805664062 ≈ 807.3 mV,即0.8073V

MQ-2的浓度值也就可以计算出为 1002/4095 * 100% = 24.5%

2.6.3.3 测试程序应用编程

由上文驱动配置说明知道,ADC采样到的数据和其计算方法,下面是获取MQ-2的浓度值的代码。

guowenxue@ubuntu20:~/igkboard/apps$ vim adc_mq2.c
/*********************************************************************************
 *      Copyright:  (C) 2022 LingYun IoT System Studio
 *                  All rights reserved.
 *
 *       Filename:  adc_mq2.c
 *    Description:  This file is mq2 concentration source code.
 *              
 *        Version:  1.0.0(2022/10/19)
 *         Author:  Guo Wenxue <guowenxue@gmail.com>
 *      ChangeLog:  1, Release initial version on "2022/10/19 22:13:26"
 *              
 ********************************************************************************/
 #include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <errno.h>

//需要读取的文件绝对路径
static char *file_path[] = {
    "/sys/bus/iio/devices/iio:device0/in_voltage_scale",
    "/sys/bus/iio/devices/iio:device0/in_voltage1_raw",
};

enum path_index {
    IN_VOLTAGE_SCALE = 0,
    IN_VOLTAGE1_RAW,
};

typedef struct adc_dev_s
{
    int raw; //原始数据,数字值
    float scale;//精度值
    float act;  //实际值
    float conc; //浓度值
}adc_dev_t;

static int file_data_read(char *filename, char *buf, size_t buf_size);

int main(int argc, char *argv[])
{
    char adc_buf[30] = {};
    adc_dev_t mq2;

    memset(&mq2, 0, sizeof(mq2));
    while(1)
    {
        if(file_data_read(file_path[IN_VOLTAGE_SCALE], adc_buf, sizeof(adc_buf)) < 0)
        {
            printf("Error : Read %s failure.\n", file_path[0]);
        }
        mq2.scale = atof(adc_buf);//将字符串转换为浮点类型
        // printf("Read ADC scale = %f\n", mq2.scale);

        if(file_data_read(file_path[IN_VOLTAGE1_RAW], adc_buf, sizeof(adc_buf)) < 0)
        {
            printf("Error : Read %s failure.\n", file_path[1]);
        }
        mq2.raw = atoi(adc_buf);//将字符串转换为整型
        // printf("Read ADC raw = %d\n", mq2.raw);

        mq2.act = (mq2.raw * mq2.scale) / 1000.f;//计算其实际电压值
        mq2.conc = ((float)mq2.raw / 4095.f) * 100.f;//计算其实际浓度值
        printf("MQ-2 实际电压值为 %.3fV , 浓度为%.1f%%\n", mq2.act, mq2.conc);
        sleep(1);
    }
    return 0;
}

//读取文件的字符串
static int file_data_read(char *filename, char *buf, size_t buf_size)
{
    int ret = 0;
    int fd = -1;

    if(!filename || !buf || !buf_size)
    {
        printf("Error filename or str \n");
        return -1;
    }

    fd = open(filename, O_RDONLY);
    if(fd < 0)
    {
        printf("Open file '%s' failure: %s\n", filename, strerror(errno));
        ret = -2;
        goto cleanup;
    }

    memset(buf, 0, buf_size);
    if(read(fd, buf, buf_size) < 0)
    {
        printf("Read data from '%s' failure: %s\n", filename, strerror(errno));
        ret = -3;
        goto cleanup;  
    }

cleanup:
    if(fd >= 0)
        close(fd);   
    
    return ret;
}

2.6.3.4 交叉编译运行测试

程序编写好之后,接下来我们再次修改 makefile 文件,添加 ds18b20 程序的编译支持。

guowenxue@ubuntu20:~/igkboard/apps$ vim makefile 
... ...
all:
        ... ...
        ${CC} adc_mq2.c -o adc_mq2

clean:
        ... ...
        @rm -f adc_mq2

然后使用 make 命令来交叉编译程序。

guowenxue@ubuntu20:~/igkboard/apps$ make

现在我们在开发板上通过scp 命令 或其它方式将编译生成的测试程序下载到开发板上。

root@igkboard:~# scp -P 2288 guowenxue@192.168.2.2:~/igkboard/apps/adc_mq2 .
guowenxue@192.168.2.2's password: 
adc_mq2                                        100% 8752   794.3KB/s   00:00 

接下来,我们给该程序加上执行权限并运行。可以正常打印当前开发板周围的危险气体浓度值。使用打火机释放甲烷进行测试,可以见到浓度骤增然后打火机关闭放气阀后浓度缓慢下降。

root@igkboard:~# chmod a+x adc_mq2
root@igkboard:~# ./adc_mq2            
MQ-2 实际电压值为 0.360V , 浓度为%10.9
MQ-2 实际电压值为 0.362V , 浓度为%11.0
MQ-2 实际电压值为 0.363V , 浓度为%11.0
MQ-2 实际电压值为 0.366V , 浓度为%11.1
MQ-2 实际电压值为 2.691V , 浓度为%81.6
MQ-2 实际电压值为 2.744V , 浓度为%83.2
MQ-2 实际电压值为 2.373V , 浓度为%71.9
MQ-2 实际电压值为 2.473V , 浓度为%75.0
MQ-2 实际电压值为 3.299V , 浓度为%100.0
MQ-2 实际电压值为 2.929V , 浓度为%88.8
MQ-2 实际电压值为 2.445V , 浓度为%74.1
MQ-2 实际电压值为 2.045V , 浓度为%62.0
MQ-2 实际电压值为 1.974V , 浓度为%59.8
MQ-2 实际电压值为 1.781V , 浓度为%54.0
MQ-2 实际电压值为 1.546V , 浓度为%46.9
MQ-2 实际电压值为 1.324V , 浓度为%40.1
MQ-2 实际电压值为 1.150V , 浓度为%34.8
MQ-2 实际电压值为 1.008V , 浓度为%30.5
MQ-2 实际电压值为 0.914V , 浓度为%27.7
MQ-2 实际电压值为 0.844V , 浓度为%25.6
MQ-2 实际电压值为 0.777V , 浓度为%23.6
MQ-2 实际电压值为 0.728V , 浓度为%22.1
MQ-2 实际电压值为 0.686V , 浓度为%20.8

2.7 I2C接口编程

版权声明

本文档所有内容文字资料由凌云实验室郭工编著,主要用于凌云嵌入式Linux教学内部使用,版权归属作者个人所有。任何媒体、网站、或个人未经本人协议授权不得转载、链接、转帖或以其他方式复制发布/发表。已经授权的媒体、网站,在下载使用时必须注明来源,违者本人将依法追究责任。

  • Copyright (C) 2022 凌云物网智科实验室·郭工

  • Author: Guo Wenxue Email: guowenxue@gmail.com QQ: 281143292

wechat_pub

2.7.1 I2C接口介绍

2.7.1.1 I2C总线简介

I2C(IIC, Inter Integrated Circuit,集成电路总线)是由Philips公司(2006年被NXP收购)在1980年开发的一种简单的 双向同步串行总线,它利用 一根时钟线(SCL, Serial Clock)一根数据线(SDA, Serial Data) 在连接总线的两个器件之间进行信息的传递,为设备之间数据交换提供了一种简单高效的方法。

img

每个连接到I2C总线上的从设备器件都有一个唯一的地址,这个地址通常可以在芯片的datasheet上找到。通常**MCU作为Master(主设备)**工作,而I2C是真正的多主设备总线,可提供仲裁和冲突检测;,但 同一时刻总线上只允许有一个Master在工作

i2c_address

总结来说,I2C总线具有以下特点:

  • 只需要 SDA、SCL 两条总线,这样芯片硬件设计、布线非常简单;

  • 它是一种同步、串行、半双工 通信方式,没有严格的波特率要求;

  • 它是一种主从模式的通信协议,通信开始总是由主设备发起,从设备被动响应请求;

  • 每个从设备都有一个独一无二的 从设备地址 用来表示该器件,同一总线上要保持该地址唯一;

  • I2C是真正的多主设备总线,可提供仲裁和冲突检测;

I2C总线传输速度分为四种模式:

  • **标准模式(Standard Mode):**100 Kbps

  • **快速模式(Fast Mode):**400 Kbps

  • **高速模式(High speed mode):**3.4 Mbps

  • **超快速模式(Ultra fast mode):**5 Mbps

I2C还有两个变体,分别专注于系统和电源应用,称为系统管理总线(SMBus)和电源管理总线(PMBus)。在嵌入式开发领域,使用I2C总线通信的场景很多很多,例如LCD上的触摸屏(Touch Screen)、音频芯片(Codec)、摄像头传感器(Camera Sensor)、EEPROM、温湿度传感器(SHT20)、光强传感器( TSL2561 )、OLED显示屏等。

所以作为一名嵌入式软硬件开发者,熟练掌握I2C接口的使用非常非常重要,这也是笔试、面试过程中必定会考到、必定会问到的知识点。

2.7.1.2 I2C从机地址

I2C总线有 7位寻址和10位寻址 两种模式,通常我们使用7bit模式。这就使得 I2C总线上理论上寻址模式的最大节点数为 128(2^7)个1024(2^10)个。但I2C有16个保留从机地址,如下表所示:

i2c_reserved

7位寻址

在7位寻址过程中,从机地址在启动信号后的第一个字节开始传输,该字节的前7位为从机地址,第8位为读写方向位,其中 0表示写,1表示读

i2c_7bit

有些芯片厂商在描述从机地址时说的是包含了读/写位的8bit地址,比如他说写地址为0x92,读地址为0x93,如下图所示。 这种情况下,你只需要将这个地址的 前7bit (b100 1001) 提取出来就可以得到其 7bit地址为 0x49.

i2c_8bit

还有一种方式可以判断厂商提供的地址是7bit模式地址还是8bit地址模式的地址,7bit地址模式下,地址的取值范围在0x07到0x78之间,若超过了这个范围,那么这个地址可能就是8bit地址。

i2c_reserved2

10位寻址

I2C总线的10bit寻址和7bit寻址是兼容的,这样就可以在同一个总线上同时使用7bit地址和10bit地址模式的设备,在进行10bit地址传输时,第一字节是一个特殊的保留地址来指示当前传输的是10bit地址。

i2c_10bit

在使用I2C的10bit地址模式的时候,只需要在初始化时配置为10bit地址模式,然后再在调用读写数据函数时传入正确的10bit地址即可。

2.7.1.3 I2C总线时序

I2C 通信协议遵循主/从层次结构,其中主设备为总线提供时钟、寻址从设备以及向从设备中写入或读取数据的设备,而从设备则是仅在主设备通过其唯一地址询问时才做出响应的设备。因此,从设备地址在总线必须唯一,另外从设备从不主动启动数据传输。

i2c_bus

SDA 和SCL 都是双向线路,都通过一个电流源或上拉电阻连接到正的电源电压。当总线空闲时,这两条线路都是高电平。连接到总线的器件输出级必须是漏极开路或集电极开路才能执行线与的功能。当总线上要发送数据时, 每传输一个数据位就由主机在 SCL 线上产生一个时钟脉冲,之后主机或从机就可以通过拉高/拉低 SDA 线来传输一个位的数据

I2C Data Timing Diagram

起始/停止信号

I2C总线为标准的主从双向通信模式,由主设备(通常是MCU)发起数据的传输,从设备被动地响应主设备的数据请求,而不能主动传输数据。当主设备需要跟从设备通信时,主设备通过发送 **起始信号(START)**来开始数据的通信。需要结束数据通信时,则发送 **停止信号(STOP)**来结束这次数据通信。

i2c_start_stop_condition

如上图所示,起始信号(S)停止信号(P) 的定义如下:

  • 起始信号:SCL为高时,SDA由高变低(SDA拉低);

  • **结束信号:**SCL为高时,SDA由低变高(SDA拉高)

关于这个时序关系我们如何才能记住呢?其实我们可以这样来看:

  • SCL在通信时发送的是时钟信号,它是高、低电平的脉冲信号,所以不能作为起始和结束信号的标志,这样只能用SDA线了;

  • I2C总线在空闲时 SCL 和 SDA 都为高电平,这样突然将 SDA 拉成低电平就可以标志要开始通信了;

  • I2C总线在通信结束后处于空闲状态,这时I2C总线的 SCL 和 SDA 都必须处于高电平状态,这样SDA由低变高就是结束信号了;

数据位发送

I2C主机在总线上给出起始信号后,需要在总线上给出要通信的8位从机设备地址,用来标识要跟哪个外设通信。

i2c_send_data

对于I2C总线来说,其实发送的 8位从机地址 也是一个字节的数据,而它由两部分组成:

  1. 芯片7bit地址: 这个地址是固化在从设备芯片里面的,在编程时可以从芯片datasheet里查到;

  2. 读写方向位(R/W): 如果该位为0(W,写),表示主机将发送数据给从机;如果该位为1(R,读),则表示要从机发送数据给主机;

总线上所有的从机在收到这个地址后,将会与自己的7bit地址相比较,如果相同,则认为自己被主机寻址选中,接下来会回复一个 ACK 报文 给主机。此外,通过判断最低方向位,就可以知道接下来主机时要发送给我,还是要从我这里读取数据了。

I2C总线在通信时每次传输1个字节的数据。数据在传送时,先传送高位,后传送地位,这种通信模式叫做 MSB(Most Significant Bit,最高有效位);反之,如果先传送低位,后传送高位,这种通信模式叫做 LSB(Least Significant Bit,最低有效位)。需要了解的是,大部分通信协议会采用 MSB ,当然这不是绝对的。

i2c_send_byte

从前面的内容我们知道,如果SCL处于高电平时,SDA的电平跳变用来标志 起始信号 和 结束信号。那如果我们在传输数据的过程中,如果数据位需要由 ‘0’ 变成 ‘1’ 或者由 ‘1’ 变成 ‘0’ 时,那又该如何处理呢?

很显然,数据位的转变不能发生在SCL为高电平期间(否则这就为起始或结束信号了),那就只能发生在 SCL 为低电平期间;同时当SCL为高电平时,数据线SDA上的电平必须保持稳定不能发生跳变。

i2c_send_data2

ACK与NAK

在I2C通信发送完一个字节的数据后,接收方(可能时主设备也可能是从设备) 应该在第9个位给发送方回复一个 ACK/NAK 位信号,用来告知对方是继续通信还是结束本次通信。

  • ACK(Acknowledgement): 在ACK/NAK时钟周期内,SDA线为低电平表示ACK信号,它用来通知对方还有数据要接收;

  • NAK(Negative Acknowledgement): 在ACK/NAK时钟周期内,SDA线为高电平表示NAK信号,它用来通知对方本次通信结束;

I2C Full Byte Timing Diagram

需要注意的是,在接收方发送ACK之前,发送方必须要释放SDA线,这样才能使得接收方能够在ACK/NAK期间拉低SDA线,这点在使用GPIO来模拟I2C时特别重要。绝大部分情况下,正常通信时都会回复ACK信号,但以下几个条件会导致产生NAK信号:

  • 主机从从设备那读完数据后会回复 NAK信号,通知从设备数据通信完成;

  • 在传输期间,接收方不能收到任何数据字节 或 收到它不能解析的命令或数据;

  • 通信方现在尚未准备好与主站进行通信,如现在正忙着执行某些其它实时指令;

写从设备时序

如下图所示,为主设备写一个字节的数据到从设备的流程:

i2c_bus_write

  • 主设备发送 START信号 开始本次通信流程;

  • 接着发送要访问的从设备7bit地址;

  • 第8个位发送0表示要写数据到从设备上;

  • 在NAK/ACK周期,会有两种情况:

    • 如果总线上没有该设备,在下一个周期总线保持高电平(1)为NAK,这样主设备就能知道从设备不在,然后发送停止信号终止本次通信;

    • 如果总线上有从设备匹配到了这个地址,则此从设备将会回复一个ACK信号,主设备收到该信号后将会继续与此从设备通信;

  • 如果主设备收到了ACK信号,接下来它就会发送1个字节(8 bits)数据给从设备;

  • 从设备在收到这个字节数据后,将会给主设备回复ACK信号;

  • 主设备在发送完这个字节数据后,给从设备发送 STOP信号 终止本次通信流程。

读从设备时序

如下图所示,为主设备从从设备读一个字节数据的流程。

i2c_bus_read

  • 主设备发送 START信号 开始本次通信流程;

  • 接着发送要访问的从设备7bit地址;

  • 第8个位发送1表示要从从设备那里读数据;

  • 在NAK/ACK周期,会有两种情况:

    • 如果总线上没有该设备,在下一个周期总线保持高电平(1)为NAK,这样主设备就能知道从设备不在,然后发送停止信号终止本次通信;

    • 如果总线上有从设备匹配到了这个地址,则此从设备将会回复一个ACK信号,主设备收到该信号后将会继续与此从设备通信;

  • 从设备在发送完ACK信号后,会继续发送1个字节(8 bits)数据给主设备;

  • 主设备在收完这个字节数据后,将会给从设备回复NAK信号,表示不需要继续通信了;

  • 接下来主设备会给从设备发送 STOP信号 终止本次通信流程。

时钟延展

I2C的时钟延展(Clock Stretching) 有时也叫做时钟拉伸。那什么是时钟延展呢?我们知道,I2C是一种同步通信模式,主设备这边通过 SCL 线给从设备提供一个精确的同步时钟信号跟从设备通信,那如果从设备的速率跟不上主设备的通信速率那该怎么办呢?这时候就可以使用 clock stretching

Clock Stretching只是I2C通信的一个可选项,实际上大部分的设备并不支持该模式。通常在I2C接口的传感器中比较常见,如温湿度传感器SHT20/SHT30就支持这种模式。这里以 SHT20 为例,MCU要通过I2C接口获取 SHT20 的温湿度采样值,但此时SHT20正处于休眠状态并没有当前的温湿度值。此时MCU会给它发送一个命令开始采样,但从开始采样到采样完成并换成相应的温湿度数据还需要一段时间,这时候就可以使用时钟延展。

那什么是 Clock Stretching 呢?在I2C的主从通信过程中,总线上的SCL时钟总是由主设备来产生和控制,但如果从机跟不上主机的速率时,I2C协议规定从机可以在数据传输的第9个位时钟(ACK)时主动将SCL时钟线拉低,通知主设备暂停此时的传输。当从设备的数据准备好后,再将SCL释放通知主设备现在数据已经就位,可以继续通信了。

i2c_clock_stretching

接下来我们将以SHT20为例,详细讲解 I2C接口的开发与使用。其中 SHT20 芯片datasheet里提到的 Holder Master 模式 就是 Clock Stretching.

sht20_hold_master

2.7.3 SHT20传感器介绍

2.7.3.1 SHT20传感器简介

SHT20 是瑞士sensirion公司的温湿度传感器中,应用最为广泛的型号,因传感器精度可以达到±3%rh、±0.3℃,同时传感器采用I2C接口数字输出。产品采用DNF封装,方便焊接,其使用寿命长达15年。在民用商用领域收到一致认可,并常年大量出货。

sht20_app

其技术参数如下:

  • 工作电压: 2.1~3.6VDC(请勿使用5V供电!!!)

  • 信号输出:I2C数字输出

  • 湿度测量范围: 0~100%RH 湿度测量精度:±3%

  • 温度测量范围: -40~125℃ 温度测量精度:±0.3℃

  • 能耗: 3.2uW (8位测量, 1次/秒)

瑞士sensirion公司的温湿度传感器共有四代:

  • **第一代:**SHT1X 系列(SHT10,SHT11,SHT15,精度区别) 已经停产 (2.4-5.5V)

  • **第二代:**SHT2X系列(SHT20,SHT21,SHT25,精度区别)(2.1-3.6V)

  • **第三代:**SHT3X-DIS系列 (SHT30,SHT31,SHT35,精度区别) (2.15 V to 5.5 V)

  • 第四代: SHT4X-DIS系列 (SHT40,SHT41,SHT45,精度区别)(1.08-3.6V)

2.7.3.2 SHT20工作原理

这里我们以SHT20为例讲解SHT系列温湿度传感器的工作原理,其芯片 datasheet 可以 点击此链接下载。datasheet中的第1章节关于芯片的介绍及其特性,作为软件工程应该了解一下,而芯片datasheet的第 5~6 章节为软件工程师在编程时需要重点了解的部分,其他章节为硬件工程师在产品选型和硬件设计需要参考的。

sht20

在芯片datasheet的 5.3 章节,它介绍到了 SHT20 温湿度传感器的7-bit I2C从设备地址为二进制 ‘1000’000’,转换成十六进制其地址为 0x40. 另外,在该节中也提到了温湿度传感器的相关操作命令如下:

sht20_commands

在 5.4 章节中我们可以了解到,SHT20在采样时有两种工作模式:hold master模式no hold master 模式,具体采用哪种工作模式由MCU发过来的命令决定。其中 Holder Master 模式(温度命令为 0xE3, 相对湿度命令为0xE5) 将会使用 Clock Stretching 机制来与MCU通信,其工作时序如下:

sht20_hold_master

而使用 no hold master 模式(温度命令为 0xF3, 相对湿度命令为0xF5),其工作时序如下:

sht20_nohold_master

在 Table7 中给出了不同采样精度下,SHT20完成采样的典型时间值和最大时间值。

sht20_timeout

此外在该章节还描述了,无论是 Hold 还是 No Hold Master Mode 模式,如果采样精度为14位的话,SHT20发过来的2字节采样数据高14位(bit28~bit42)相应的温湿度值,而最低两个位(bit43、bit44)用来表示传输状态。其中bit44目前未用,而 bit43 如果为0表示温度值为1则表示相对湿度值

在芯片datasheet第6章,详细描述了如何将这14个位的数据(下图中的Srh 或 St)转换成相应的温、湿度值:

sht20_formula1

sht20_formula2

在芯片datasheet的 5.6 章节讲到如何配置SHT20温度传感器的精度:

sht20_reguser

在芯片datasheet 5.7节中,详细描述了2字节数据后面紧跟的1字节CRC校验和公式:

sht20_crc

在芯片datasheet的 5.5 章节提到,MCU给SHT20传感器发送 0xFE命令 可以完成芯片的软件复位。通常,我们在让SHT20工作前,会让它软件复位以下。

sht20_reset

下面是使用逻辑分析仪抓取的给 SHT20 温湿度传感器发送 no hold master 模式采样温度命令(0xF3)的时序图。

sht20_clock1

下面是从SHT20温度传感器读取温度值的时序图:

sht20_clock2

2.7.3.3 模块硬件连接说明

SHT20传感器的工作电压范围为2.1~3.6V,所以其电源需连接3.3v。这样SHT20在与开发板相连时,主要连接如下三个引脚:

  1. GND,该引脚要连到开发板的GND扩展引脚上;

  2. VDD, 该引脚要连到开发板的 3.3v ;

  3. SCL, 是I2C通信的时钟线,连接开发板上的I2C SCL引脚上;

  4. SDA, 是I2C通信的数据线,连接开发板上的I2C SDA引脚上;

在IGKBoard开发板上的 40Pin 扩展引脚上,提供了 I2C1 这路 I2C 接口。在Linux系统启动时如果没有配置使能这些串口,它们将会默认工作在GPIO 功能模式。

40pin_hat

如下是 SHT20 温湿度传感器连接到 IGKBoard上的实物示意图。

igkboard_sht20

2.7.3.4 驱动配置使用说明

在前面,我们将 SHT20 的 I2C 接口引脚连到了IGKBoard开发板扩展接口的 **#3 (I2C1_SDA) 和#5(I2C_SCL)**上 ,该这两个引脚在系统启动时有可能 默认作为GPIO功能使用。如果想作为I2C通信引脚使用的话,我们需要修改开发板上的DTOverlay配置文件,添加该引脚的 I2C 协议支持。

具体方法为修改 eMMC/TF卡 启动介质的 boot 分区下的 config.txt 文件,修改 dtoverlay_i2c 选项中添加 I2C1 支持即可。

root@igkboard:~# vim /run/media/mmcblk1p1/config.txt 

# Enable I2C overlay
dtoverlay_i2c=1

root@igkboard:~# sync

如果系统默认没有挂载这个分区,则需要先使用 mount 命令来手动挂载。

root@igkboard:~# mount | grep mmcblk
/dev/mmcblk1p2 on / type ext4 (rw,relatime)

root@igkboard:~# mkdir -p /run/media/mmcblk1p1/
root@igkboard:~# mount /dev/mmcblk1p1 /run/media/mmcblk1p1/

修改完成后重启系统,系统启动时将会自动加载 I2C1 协议驱动。待系统启动完成后,我们可以查看 /dev/i2c-0 设备是否存在,从而判断驱动是否正常加载。

root@igkboard:~# ls /dev/i2c-*
/dev/i2c-0  /dev/i2c-1

2.7.3.5 I2C接口调试命令

在Linux系统下,i2c-tools (点此链接下载源码)是一个专门用来调试I2C接口设备的开源工具集,在嵌入式Linux系统下调试I2C设备时会经常用到,该工具及包含以下几个命令:

  • i2cdetect 查看开发板上包含的 I2C总线和各总线上的外设;

  • i2cdump 查看I2C总线上某个从设备的所有寄存器值;

  • i2cget 查看I2C总线上某个从设备的指定寄存器值;

  • i2cset 设置I2C总线上某个从设备的指定寄存器值;

接下来我们将详细了解一下这些 i2c-tools 相关命令:

i2cdetect 命令

i2cdetect 命令可以查看开发板上包含的 I2C总线和各总线上的外设,下面是其使用方法:

root@igkboard:~# i2cdetect 
Error: No i2c-bus specified!
Usage: i2cdetect [-y] [-a] [-q|-r] I2CBUS [FIRST LAST]
       i2cdetect -F I2CBUS
       i2cdetect -l
  I2CBUS is an integer or an I2C bus name
  If provided, FIRST and LAST limit the probing range.

如下所示,我们可以使用 i2cdetect -l 命令查看当前设备上所有的 I2C 总线。从命令执行的结果我们可以看到,IGKBoard开发板上现在有两路I2C总线 /dev/i2c-0(40Pin扩展引脚)和 /dev/i2c-1(板载).

root@igkboard:~#  i2cdetect -l
i2c-0   i2c             21a0000.i2c                             I2C adapter
i2c-1   i2c             21a4000.i2c                             I2C adapter

如下所示,我们可以使用 i2cdetect -y 0 命令查看 /dev/i2c-0 总线下的所有 I2C从设备。其中参数 -y 表示取消交互模式直接执行命令,而 0 则表示探测 i2c-0 总线。命令执行结果上显示的 0x40 表示该地址上的从设备(SHT20 温湿度传感器)存在且未被Linux内核驱动使用。

root@igkboard:~# i2cdetect -y 0
     0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f
00:                         -- -- -- -- -- -- -- -- 
10: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 
20: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 
30: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 
40: 40 -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 
50: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 
60: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 
70: -- -- -- -- -- -- -- -- 

如果某个设备已经被Linux内核驱动所使用,那这个地址(0x6f, ISL1208 RTC芯片)上将会显示 UU,探测程序将会跳过该设备。

root@igkboard:~# i2cdetect -y 1
     0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f
00:                         -- -- -- -- -- -- -- -- 
10: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 
20: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 
30: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 
40: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 
50: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 
60: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- UU 

i2cdump 命令

i2cdump 可以查看I2C总线上某个从设备的所有寄存器值 或某个范围内的寄存器值。其具体使用方法如下:

root@igkboard:~# i2cdump
Error: No i2c-bus specified!
Usage: i2cdump [-f] [-y] [-r first-last] [-a] I2CBUS ADDRESS [MODE [BANK [BANKREG]]]
  I2CBUS is an integer or an I2C bus name
  ADDRESS is an integer (0x08 - 0x77, or 0x00 - 0x7f if -a is given)
  MODE is one of:
    b (byte, default)
    w (word)
    W (word on even register addresses)
    s (SMBus block, deprecated)
    i (I2C block)
    c (consecutive byte)
    Append p for SMBus PEC

使用下面命令可以查看 SHT20 温湿度传感器的采样值,其中命令行参数选项解析如下:

  • -f 强制(force) 访问该设备;

  • -r 指定要访问的寄存器范围,这里仅仅访问 0x00-0x0f 这16个寄存器,如果不指定则访问所有的寄存器;

  • -y 取消交换模式,直接执行命令

  • 1 表示要访问I2C总线 i2c-1

  • 0x40 表示要访问I2C从设备地址为0x40的设备,即SHT20温湿度传感器

root@igkboard:~#  i2cdump -f -r 0x00-0x0f -y 1 0x40 
No size specified (using byte-data access)
     0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f    0123456789abcdef
00: XX XX XX 69 XX 97 3a 3a XX 06 XX XX XX XX XX 02    XXXiX?::X?XXXXX?

有些盗版的SHT20芯片可能不会显示上面的温度值,下面是SHT20温湿度传感器读取到的寄存器值的解析:

  • 0x03寄存器值(0x69)为温度值的MSB部分,这里忽略LSB部分,根据datasheet里提到的转换公式:T = - 46.85 +175.72 * S/2^16 = -46.85+175.72*0x6900/65536 = 25.22 ‘C

  • 0x05寄存器值(0x97)为相对湿度值的MSB部分,这里忽略LSB部分,根据datasheet里提到的转换公式:RH = RH = -6 +125 * S/2^16 = -6 +125*0x9700/65536 = 67.73%

i2cget 命令

i2cget 命令可以查看I2C总线上某个从设备的寄存器值,其用法如下:

root@igkboard:~# i2cget
Usage: i2cget [-f] [-y] [-a] I2CBUS CHIP-ADDRESS [DATA-ADDRESS [MODE [LENGTH]]]
  I2CBUS is an integer or an I2C bus name
  ADDRESS is an integer (0x08 - 0x77, or 0x00 - 0x7f if -a is given)
  MODE is one of:
    b (read byte data, default)
    w (read word data)
    c (write byte/read byte)
    s (read SMBus block data)
    i (read I2C block data)
    Append p for SMBus PEC
  LENGTH is the I2C block data length (between 1 and 32, default 32)

如下面命令为读取 SHT20温湿度传感器上0x03寄存器值,其命令行参数解析如下:

  • -f 强制(force) 访问该设备;

  • -y 取消交换模式,直接执行命令

  • 1 表示要访问I2C总线 i2c-1

  • 0x40 表示要访问I2C从设备地址为0x40的设备,即SHT20温湿度传感器

  • 0x03 表示要读的寄存器

root@igkboard:~# i2cget -f -y 1 0x40 0x03 
69

i2cset 命令

i2cset 命令可以设置I2C总线上某个从设备的单个寄存器值,其用法如下:

root@igkboard:~# i2cset
Usage: i2cset [-f] [-y] [-m MASK] [-r] [-a] I2CBUS CHIP-ADDRESS DATA-ADDRESS [VALUE] ... [MODE]
  I2CBUS is an integer or an I2C bus name
  ADDRESS is an integer (0x08 - 0x77, or 0x00 - 0x7f if -a is given)
  MODE is one of:
    c (byte, no value)
    b (byte data, default)
    w (word data)
    i (I2C block data)
    s (SMBus block data)
    Append p for SMBus PEC

如下面命令为当初调试 DA7212 Codec音频驱动时,直接写相关寄存器的操作命令,其命令行参数解析如下:

  • -f 强制(force) 访问该设备,因为该设备被内核驱动使用了,所以访问时必须指定该参数;

  • -y 取消交换模式,直接执行命令

  • 1 表示要访问I2C总线 i2c-1

  • 0x1a 表示要访问I2C从设备地址为0x1a的设备,即DA7212 Codec芯片;

  • 0x20 表示要写的寄存器起始地址

  • 0x00~0x00 要写入这些寄存器的值

  • i 采用 I2C block data 模式;

root@board:~# i2cset -f -y 1 0x1a 0x20 0x00 0x10 0x0a 0x08 0x0d 0x47 0x21 0x04 0x01 0x80 0x32 0x00 0x00 0x00 0x00 i

如果只写某一个寄存器,则可以使用:

root@board:~# i2cset -f -y 1 0x1a 0x6f 0x98 i

2.7.4 Linux下I2C编程接口

一般我们要使用 I2C 接口访问某个设备时,通常会在Linux内核里编写相应的设备驱动,调用内核空间的I2C通信接口实现CPU与I2C从设备之间的通信。但编写Linux内核驱动程序毕竟有一定的门槛,需要熟悉Linux内核相关的各种开发规范,有时为了快速地测试、验证一个I2C设备的功能,临时编写相应的驱动比较麻烦。另外有些I2C设备的功能十分简单,为此编写一个单独的驱动程序就显得有点“兴师动众”了。

为方便用户能够在应用程序空间轻松实现I2C通信,linux内核源码 drivers/i2c/i2c-dev.c 提供了一个通用的I2C通信设备,该驱动使得每个使能的I2C适配器在/dev/目录下都会创建一个字符设备 /dev/i2c-x(其中x为0,1,2…)。通过调用该设备文件提供的相应系统调用接口函数(open/read/write/ioctl/close),就可以实现与I2C从设备的通信了。

i2cdev_system

i2c-dev设备驱动在用户空间有两种工作模式:

  • 普通的文件IO模式:这种模式主要是调用read()/write()函数来实现I2C通信,用户在使用的时候就像读写文件一样,比较简单。

  • 设备驱动ioctl模式:这种模式主要是调用ioctl()函数来封装I2C通信消息,从而实现I2C通信。它对应用程序员的要求很高,需要了解一些I2C通信的硬件知识,如I2C通信时序、地址等。

下面以 AT24C02 EEPROM为例讲解这两种模式的区别。

普通的设备驱动通过 read()/write() 函数实现I2C的通信,这类函数只能对应一条I2C消息,即包含有一个起始信号和终止信号的这类消息。如下AT24C02的 Byte Write、Page Write、Current Address Read等几个通信时序:

eeprom_i2c1

eeprom_i2c2

eeprom_i2c3

但是对于下面的 Random Read时序,在 Dummy Write阶段发送要读的地址之后,并没有跟Stop信号,而是再次发送起始信号开始读EEPROM里的数据了。对于这种情况,read(),write()函数就不能正常读写了。这是因为它们在write()地址之后总线上会有stop,之后read(),这就与下面的通信时序不符了(中间没有stop)。在这种情况下,我们就必须使用ioctl函数来发送两条消息,这样中间就可以没有stop了,发送完这两条消息后再给stop信号。

eeprom_i2c4

接下来,我们将以前面讲解的SHT20温湿度传感器为例,分别讲解这两种模式编程接口。

2.7.4.1 普通文件IO模式

再该模式下,对于I2C设备的操作就跟文件I/O一样,open() 打开文件之后,调用write()read() 读写内容即可。唯一的差别是,在打开之后需要调用 ioctl() 设置I2C的模式和从设备地址,具体代码如下:

guowenxue@ubuntu20:~/igkboard/apps$ vim sht20_fops.c 
/*********************************************************************************
 *      Copyright:  (C) 2024 LingYun IoT System Studio
 *                  All rights reserved.
 *
 *       Filename:  sht20_fops.c
 *    Description:  This file is temperature and relative humidity sensor SHT20 code
 *
 *        Version:  1.0.0(2024/05/08)
 *         Author:  Guo Wenxue <guowenxue@gmail.com>
 *      ChangeLog:  1, Release initial version on "2024/05/08 12:13:26"
 *
 * Pin connection:
 *
 *               SHT20 Module            IGKBoard
 *                   VCC      <----->      3.3V
 *                   SDA      <----->      #Pin3(I2C1_SDA)
 *                   SCL      <----->      #Pin5(I2C1_SCL)
 *                   GND      <----->      GND
 *
 * /run/media/mmcblk1p1/config.txt:
 *
 *          # Eanble I2C overlay
 *          dtoverlay_i2c=1
 **
 ********************************************************************************/

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <string.h>
#include <unistd.h>
#include <time.h>
#include <errno.h>
#include <fcntl.h>
#include <sys/ioctl.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <linux/types.h>
#include <linux/i2c.h>
#include <linux/i2c-dev.h>

int sht20_checksum(uint8_t *data, int len, int8_t checksum);
static inline void msleep(unsigned long ms);
static inline void dump_buf(const char *prompt, uint8_t *buf, int size);

int main(int argc, char **argv)
{
    int             fd, rv;
    float           temp, rh;
    char           *i2c_dev = NULL;
    uint8_t         buf[4];


    if( argc != 2)
    {
        printf("This program used to do I2C test by SHT20 sensor.\n");
        printf("Usage: %s /dev/i2c-x\n", argv[0]);
        return 0;
    }

    i2c_dev = argv[1];

    /*+--------------------------------+
     *|     open /dev/i2c-x device     |
     *+--------------------------------+*/
    if( (fd=open(i2c_dev, O_RDWR)) < 0)
    {
        printf("i2c device open failed: %s\n", strerror(errno));
        return -1;
    }

    /*+--------------------------------+
     *| set I2C mode and slave address |
     *+--------------------------------+*/
    ioctl(fd, I2C_TENBIT, 0);    /* Not 10-bit but 7-bit mode */
    ioctl(fd, I2C_SLAVE, 0x40);  /* set SHT2x slava address 0x40 */

    /*+--------------------------------+
     *|   software reset SHT20 sensor  |
     *+--------------------------------+*/

    buf[0] = 0xFE;
    write(fd, buf, 1);

    msleep(50);


    /*+--------------------------------+
     *|   trigger temperature measure  |
     *+--------------------------------+*/

    buf[0] = 0xF3;
    write(fd, buf, 1);

    msleep(85); /* datasheet: typ=66, max=85 */

    memset(buf, 0, sizeof(buf));
    read(fd, buf, 3);
    dump_buf("Temperature sample data: ", buf, 3);

    if( !sht20_checksum(buf, 2, buf[2]) )
    {
        printf("Temperature sample data CRC checksum failure.\n");
        goto cleanup;
    }

    temp = 175.72 * (((((int) buf[0]) << 8) + buf[1]) / 65536.0) - 46.85;


    /*+--------------------------------+
     *|    trigger humidity measure    |
     *+--------------------------------+*/

    buf[0] = 0xF5;
    write(fd, buf, 1);

    msleep(29); /* datasheet: typ=22, max=29 */

    memset(buf, 0, sizeof(buf));
    read(fd, buf, 3);
    dump_buf("Relative humidity sample data: ", buf, 3);

    if( !sht20_checksum(buf, 2, buf[2]) )
    {
        printf("Relative humidity sample data CRC checksum failure.\n");
        goto cleanup;
    }

    rh = 125 * (((((int) buf[0]) << 8) + buf[1]) / 65536.0) - 6;

    /*+--------------------------------+
     *|    print the measure result    |
     *+--------------------------------+*/

    printf("Temperature=%lf 'C relative humidity=%lf%%\n", temp, rh);

cleanup:
    close(fd);
    return 0;
}

int sht20_checksum(uint8_t *data, int len, int8_t checksum)
{
    int8_t crc = 0;
    int8_t bit;
    int8_t byteCtr;

    //calculates 8-Bit checksum with given polynomial: x^8 + x^5 + x^4 + 1
    for (byteCtr = 0; byteCtr < len; ++byteCtr)
    {
        crc ^= (data[byteCtr]);
        for ( bit = 8; bit > 0; --bit)
        {
            /* x^8 + x^5 + x^4 + 1 = 0001 0011 0001 = 0x131 */
            if (crc & 0x80) crc = (crc << 1) ^ 0x131;
            else crc = (crc << 1);
        }
    }

    if (crc != checksum)
        return 0;
    else
        return 1;
}

static inline void msleep(unsigned long ms)
{
    struct timespec cSleep;
    unsigned long ulTmp;

    cSleep.tv_sec = ms / 1000;
    if (cSleep.tv_sec == 0)
    {
        ulTmp = ms * 10000;
        cSleep.tv_nsec = ulTmp * 100;
    }
    else
    {
        cSleep.tv_nsec = 0;
    }

    nanosleep(&cSleep, 0);
    return ;
}

static inline void dump_buf(const char *prompt, uint8_t *buf, int size)
{
    int          i;

    if( !buf )
    {
        return ;
    }

    if( prompt )
    {
        printf("%-32s ", prompt);
    }

    for(i=0; i<size; i++)
    {
        printf("%02x ", buf[i]);
    }
    printf("\n");

    return ;
}

程序编写好之后,接下来我们再次修改 makefile 文件,添加 sht20 程序的编译支持。

guowenxue@ubuntu20:~/igkboard/apps$ vim makefile
... ...
all:
        ... ...
        ${CC} sht20_fops.c -o sht20_fops

clean:
		... ...
        @rm -f sht20_fops

然后使用 make 命令来交叉编译程序。

guowenxue@ubuntu20:~/igkboard/apps$ make

现在我们在开发板上通过scp 命令 或其它方式将编译生成的测试程序下载到开发板上。

root@igkboard:~# scp -P 2288 guowenxue@192.168.2.2:~/igkboard/apps/sht20_fops .        
guowenxue@192.168.2.2's password: 
sht20_fops                                        100% 8772   610.5KB/s   00:00 

接下来,我们给该程序加上执行权限并运行,可以采样获取到当前的温湿度。

root@igkboard:~# chmod a+x sht20_fops 

root@igkboard:~# ./sht20_fops 
This program used to do I2C test by SHT20 sensor.
Usage: ./sht20_fops /dev/i2c-x

root@igkboard:~# ./sht20_fops /dev/i2c-0 
Temperature sample data:         69 b0 29 
Relative humidity sample data:   73 da c9 
Temperature=25.694561 'C relative humidity=50.568146%

2.7.4.2 设备驱动ioctl模式

设备驱动ioctl模式(即用户态驱动模式),和Linux内核里的I2C设备驱动实现原理差不多,都是封装i2c_msg结构体消息,通过i2c_transfer()函数和I2C从设备通信。区别在于,一般的I2C设备驱动需要在内核态封装了该功能;而用户态驱动,可以直接在应用程序空间,根据实际需要选择合适的I2C驱动方式实现。

下面是SHT20温湿度传感器采用 设备驱动 ioctl() 模式实现温湿度采样的示例代码:

guowenxue@ubuntu20:~/igkboard/apps$ vim sht20_ioctl.c
/*********************************************************************************
 *      Copyright:  (C) 2024 LingYun IoT System Studio
 *                  All rights reserved.
 *
 *       Filename:  sht20_ioctl.c
 *    Description:  This file is temperature and relative humidity sensor SHT20 code
 *
 *        Version:  1.0.0(2024/05/08)
 *         Author:  Guo Wenxue <guowenxue@gmail.com>
 *      ChangeLog:  1, Release initial version on "2024/05/08 12:13:26"
 *
 * Pin connection:
 *
 *               SHT20 Module            IGKBoard
 *                   VCC      <----->      3.3V
 *                   SDA      <----->      #Pin3(I2C1_SDA)
 *                   SCL      <----->      #Pin5(I2C1_SCL)
 *                   GND      <----->      GND
 *
 * /run/media/mmcblk1p1/config.txt:
 *
 *          # Eanble I2C overlay
 *          dtoverlay_i2c=1
 **
 ********************************************************************************/

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <string.h>
#include <unistd.h>
#include <time.h>
#include <errno.h>
#include <fcntl.h>
#include <sys/ioctl.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <linux/types.h>
#include <linux/i2c.h>
#include <linux/i2c-dev.h>

int i2c_write(int fd, uint8_t slave_addr, uint8_t *data, int len);
int i2c_read(int fd, uint8_t slave_addr, uint8_t *buf, int size);
int sht20_checksum(uint8_t *data, int len, int8_t checksum);
static inline void msleep(unsigned long ms);
static inline void dump_buf(const char *prompt, uint8_t *buf, int size);

int main(int argc, char **argv)
{
    int             fd, rv;
    float           temp, rh;
    char           *i2c_dev = NULL;
    uint8_t         buf[4];


    if( argc != 2)
    {
        printf("This program used to do I2C test by SHT20 sensor.\n");
        printf("Usage: %s /dev/i2c-x\n", argv[0]);
        return 0;
    }

    i2c_dev = argv[1];

    /*+--------------------------------+
     *|     open /dev/i2c-x device     |
     *+--------------------------------+*/
    if( (fd=open(i2c_dev, O_RDWR)) < 0)
    {
        printf("i2c device open failed: %s\n", strerror(errno));
        return -1;
    }

    /*+--------------------------------+
     *|   software reset SHT20 sensor  |
     *+--------------------------------+*/

    buf[0] = 0xFE;
    i2c_write(fd, 0x40, buf, 1);

    msleep(50);


    /*+--------------------------------+
     *|   trigger temperature measure  |
     *+--------------------------------+*/

    buf[0] = 0xF3;
    i2c_write(fd, 0x40, buf, 1);

    msleep(85); /* datasheet: typ=66, max=85 */

    memset(buf, 0, sizeof(buf));
    i2c_read(fd, 0x40, buf, 3);
    dump_buf("Temperature sample data: ", buf, 3);

    if( !sht20_checksum(buf, 2, buf[2]) )
    {
        printf("Temperature sample data CRC checksum failure.\n");
        goto cleanup;
    }

    temp = 175.72 * (((((int) buf[0]) << 8) + buf[1]) / 65536.0) - 46.85;


    /*+--------------------------------+
     *|    trigger humidity measure    |
     *+--------------------------------+*/

    buf[0] = 0xF5;
    i2c_write(fd, 0x40, buf, 1);

    msleep(29); /* datasheet: typ=22, max=29 */

    memset(buf, 0, sizeof(buf));
    i2c_read(fd, 0x40, buf, 3);
    dump_buf("Relative humidity sample data: ", buf, 3);

    if( !sht20_checksum(buf, 2, buf[2]) )
    {
        printf("Relative humidity sample data CRC checksum failure.\n");
        goto cleanup;
    }

    rh = 125 * (((((int) buf[0]) << 8) + buf[1]) / 65536.0) - 6;

    /*+--------------------------------+
     *|    print the measure result    |
     *+--------------------------------+*/

    printf("Temperature=%lf 'C relative humidity=%lf%%\n", temp, rh);

cleanup:
    close(fd);
    return 0;
}

int sht20_checksum(uint8_t *data, int len, int8_t checksum)
{
    int8_t crc = 0;
    int8_t bit;
    int8_t byteCtr;

    //calculates 8-Bit checksum with given polynomial: x^8 + x^5 + x^4 + 1
    for (byteCtr = 0; byteCtr < len; ++byteCtr)
    {
        crc ^= (data[byteCtr]);
        for ( bit = 8; bit > 0; --bit)
        {
            /* x^8 + x^5 + x^4 + 1 = 0001 0011 0001 = 0x131 */
            if (crc & 0x80) crc = (crc << 1) ^ 0x131;
            else crc = (crc << 1);
        }
    }

    if (crc != checksum)
        return 0;
    else
        return 1;
}

int i2c_write(int fd, uint8_t slave_addr, uint8_t *data, int len)
{
    struct i2c_rdwr_ioctl_data i2cdata;
    int rv = 0;

    if( !data || len<= 0)
    {
        printf("%s() invalid input arguments!\n", __func__);
        return -1;
    }

    i2cdata.nmsgs = 1;
    i2cdata.msgs = malloc( sizeof(struct i2c_msg)*i2cdata.nmsgs );
    if ( !i2cdata.msgs )
    {
        printf("%s() msgs malloc failed!\n", __func__);
        return -2;
    }

    i2cdata.msgs[0].addr = slave_addr;
    i2cdata.msgs[0].flags = 0; //write
    i2cdata.msgs[0].len = len;
    i2cdata.msgs[0].buf = malloc(len);
    if( !i2cdata.msgs[0].buf )
    {
        printf("%s() msgs malloc failed!\n", __func__);
        rv = -3;
        goto cleanup;
    }
    memcpy(i2cdata.msgs[0].buf, data, len);


    if( ioctl(fd, I2C_RDWR, &i2cdata)<0 )
    {
        printf("%s() ioctl failure: %s\n", __func__, strerror(errno));
        rv = -4;
        goto cleanup;
    }

cleanup:
    if( i2cdata.msgs[0].buf )
        free(i2cdata.msgs[0].buf);

    if( i2cdata.msgs )
        free(i2cdata.msgs);

    return rv;
}

int i2c_read(int fd, uint8_t slave_addr, uint8_t *buf, int size)
{
    struct i2c_rdwr_ioctl_data i2cdata;
    int rv = 0;

    if( !buf || size<= 0)
    {
        printf("%s() invalid input arguments!\n", __func__);
        return -1;
    }

    i2cdata.nmsgs = 1;
    i2cdata.msgs = malloc( sizeof(struct i2c_msg)*i2cdata.nmsgs );
    if ( !i2cdata.msgs )
    {
        printf("%s() msgs malloc failed!\n", __func__);
        return -2;
    }

    i2cdata.msgs[0].addr = slave_addr;
    i2cdata.msgs[0].flags = I2C_M_RD; //read
    i2cdata.msgs[0].len = size;
    i2cdata.msgs[0].buf = buf;
    memset(buf, 0, size);

    if( ioctl(fd, I2C_RDWR, &i2cdata)<0 )
    {
        printf("%s() ioctl failure: %s\n", __func__, strerror(errno));
        rv = -4;
    }

    free( i2cdata.msgs );
    return rv;
}


static inline void msleep(unsigned long ms)
{
    struct timespec cSleep;
    unsigned long ulTmp;

    cSleep.tv_sec = ms / 1000;
    if (cSleep.tv_sec == 0)
    {
        ulTmp = ms * 10000;
        cSleep.tv_nsec = ulTmp * 100;
    }
    else
    {
        cSleep.tv_nsec = 0;
    }

    nanosleep(&cSleep, 0);
    return ;
}

static inline void dump_buf(const char *prompt, uint8_t *buf, int size)
{
    int          i;

    if( !buf )
    {
        return ;
    }

    if( prompt )
    {
        printf("%-32s ", prompt);
    }

    for(i=0; i<size; i++)
    {
        printf("%02x ", buf[i]);
    }
    printf("\n");

    return ;
}

程序编写好之后,接下来我们再次修改 makefile 文件,添加 sht20 程序的编译支持。

guowenxue@ubuntu20:~/igkboard/apps$ vim makefile
... ...
all:
        ... ...
        ${CC} sht20_ioctl.c -o sht20_ioctl

clean:
		... ...
        @rm -f sht20_ioctl

然后使用 make 命令来交叉编译程序。

guowenxue@ubuntu20:~/igkboard/apps$ make

现在我们在开发板上通过scp 命令 或其它方式将编译生成的测试程序下载到开发板上。

root@igkboard:~# scp -P 2288 guowenxue@192.168.2.2:~/igkboard/apps/sht20_ioctl .        
guowenxue@192.168.2.2's password: 
sht20_ioctl                                        100% 8772   610.5KB/s   00:00 

接下来,我们给该程序加上执行权限并运行,可以采样获取到当前的温湿度。

root@igkboard:~# chmod a+x sht20_ioctl 

root@igkboard:~# ./sht20_ioctl 
This program used to do I2C test by SHT20 sensor.
Usage: ./sht20_ioctl /dev/i2c-x

root@igkboard:~# ./sht20_ioctl /dev/i2c-0 
Temperature sample data:         69 b0 29 
Relative humidity sample data:   71 32 68 
Temperature=25.694561 'C relative humidity=49.271149%

2.8 SPI接口编程

版权声明

本文档所有内容文字资料由凌云实验室郭工编著,主要用于凌云嵌入式Linux教学内部使用,版权归属作者个人所有。任何媒体、网站、或个人未经本人协议授权不得转载、链接、转帖或以其他方式复制发布/发表。已经授权的媒体、网站,在下载使用时必须注明来源,违者本人将依法追究责任。

  • Copyright (C) 2022 凌云物网智科实验室·郭工

  • Author: Guo Wenxue Email: guowenxue@gmail.com QQ: 281143292

wechat_pub

2.8.1 SPI 接口介绍

2.8.1.1 SPI 总线简介

SPI(Serial Peripheral Interface,串行外设接口)是 Motorola 公司推出的一种同步串行通信接口,用于在数字系统之间传输数据。它通常用于连接微控制器(MCU)和外围设备,例如传感器、存储器、显示器等。SPI接口由四条线构成:

  1. 主设备输出(Master Out Slave In,MOSI):主设备向从设备发送数据的输出线。

  2. 从设备输出(Master In Slave Out,MISO):从设备向主设备发送数据的输出线。

  3. 时钟线(Serial Clock,SCK):由主设备控制的时钟信号,用于同步数据传输。

  4. 片选线(Slave Select,SS):用于选择特定的从设备与主设备进行通信。

SPI通信是全双工的,意味着主设备和从设备可以同时发送和接收数据。通信过程中,主设备通过时钟线发送时钟信号,同时发送数据到从设备的MOSI线,从设备则通过MISO线回传数据给主设备。通常,每个从设备都有一个对应的片选线,主设备通过片选线选择要与之通信的从设备。

spi_bus

SPI通信的速度可以根据应用的需求进行调整,通常速度可以从几百kHz到数十MHz不等。SPI接口的简单性和灵活性使其在许多嵌入式系统中广泛应用。下面是 SPI 通信的优缺点:

  • SPI 优点:支持全双工通信、通信简单、数据传输速率快

  • SPI 缺点:没有指定的流控制,没有应答机制确认是否接收到数据,所以跟IIC 总线协议比较在数据可靠性上有一定的缺陷。

2.8.1.2 SPI传输模式

CPOL(Clock Polarity)和CPHA(Clock Phase)是SPI通信中的两个重要参数,用于定义时钟信号的极性和相位。它们决定了在时钟信号的不同状态下数据的采样和传输时机。

  • CPOL(Clock Polarity):CPOL定义了时钟信号在空闲状态(即未传输数据时)的电平。当CPOL为0时,时钟信号在空闲状态为低电平;当CPOL为1时,时钟信号在空闲状态为高电平。

  • CPHA(Clock Phase):CPHA定义了数据采样和传输的时机。在SPI通信中,数据的采样和传输是在时钟的上升沿或下降沿完成的,CPHA用于指定在何种时钟边沿进行数据传输。当CPHA为0时,数据在时钟信号的第一个边沿(上升沿或下降沿)进行传输;当CPHA为1时,数据在时钟信号的第二个边沿进行传输。

spi_four_modes

SPI 通信根据 CPOL(时钟极性)和 CPHA(时钟相位)的不同有4 种不同的模式,不同的 SPI 从设备芯片在出厂时就配置为某种模式了,这是不能改变的。但我们的通信双方必须是工作在同一模式下,这样我们就需要在 SPI 主设备这边根据从设备的模块来调整。

  • 模式0:CPOL=0,CPHA=0。SCLK 串行时钟线空闲是低电平,数据在SCLK 的第一个时钟沿(上升沿)被采样,在SCLK 下降沿切换。

  • 模式1:CPOL=0,CPHA=1。SCLK 串行时钟线空闲是低电平,数据在SCLK 的第一个时钟沿(下降沿)被采样,在SCLK 上升沿切换。

  • 模式2:CPOL=1,CPHA=0。SCLK 串行时钟线空闲是高电平,数据在SCLK 的第一个时钟沿(下降沿)被采样,在SCLK 上升沿切换。

  • 模式3:CPOL=1,CPHA=1。SCLK 串行时钟线空闲是高电平,数据在SCLK 的第一个时钟沿(上升沿)被采样,在SCLK 下降沿切换。

2.8.1.3 SPI数据交换

下图显示了单个主机和从机之间的典型SPI 连接,实际上 SPI 的全双工传输是靠主从双方的移位寄存器(Shift Register, SSPSR)来完成的。

spi_shift_register

SPI 与其它接口协议不同,主从设备在通信时实际上时通过两个移位寄存器来实现数据交换的过程,如下图所示。

spi_data_exchange.gif

SPI 主设备与从设备的通信过程如下:

  • 首先SPI 主设备拉低要通信的 SPI 从设备的 CS 片选引脚,从而选中该芯片工作;

  • 接下来 SPI 主设备通过 SCK 线发送相应的通信时钟信号,其数据发送与采样时间与SPI的四种模式相关;

  • 主设备在每个时钟信号相应时刻通过 MOSI 线发送移位寄存器中的一个 bit 的数据给 SPI 从设备;

  • SPI 从设备在采样收到这个 bit 的数据时,会将自己的移位寄存器中的一个bit数据通过MISO线发送给主设备;

  • 这样在8个时钟周期之后,SPI主/从设备两边的移位寄存器就完成了一个字节的数据交换;

由此可知,主设备在发送一个字节的同时,必将会从从设备端收到一个字节的数据。这样,SPI 的读写操作都是通过写操作来实现的。

  • 如果主设备需要写一个字节数据给从设备,只需要发送该字节的数据即可。当然在发送的同时也将会收到一个字节的数据,此时我们通常不用关心接收到的这个字节数据;

  • 如果主设备需要从从设备那读一个字节数据,只需要发送一个字节的 Dummy数据即可,这里的 Dummy 是指本身并不携带有用信息的数据。这时因为SPI在发送一个字节时必定会将收到一个字节数据,而这个数据就是我们想要读到的数据。

2.8.2 SPI 回环测试

2.8.2.1 驱动配置使用说明

在IGKBoard开发板上的 40Pin 扩展引脚上,提供了 SPI1 这路 SPI 接口。在Linux系统启动时如果没有配置使能这些串口,它们将会默认工作在GPIO 功能模式。

40pin_spi

如果想使能40Pin上的 SPI 通信引脚的话,我们需要修改开发板上的DTOverlay配置文件,添加该引脚的 SPI 协议支持。具体方法为修改 eMMC/TF卡 启动介质的 boot 分区下的 config.txt 文件,修改 dtoverlay_spi 选项中添加 SPI1 支持即可。

root@igkboard:~# vim /run/media/mmcblk1p1/config.txt 

# Enable SPI overlay, SPI1 conflict with UART8(NB-IoT/4G module)
dtoverlay_spi=1

root@igkboard:~# sync

如果系统默认没有挂载这个分区,则需要先使用 mount 命令来手动挂载。

root@igkboard:~# mount | grep mmcblk
/dev/mmcblk1p2 on / type ext4 (rw,relatime)

root@igkboard:~# mkdir -p /run/media/mmcblk1p1/
root@igkboard:~# mount /dev/mmcblk1p1 /run/media/mmcblk1p1/

修改完成后重启系统,系统启动时将会自动加载 SPI1 协议驱动。待系统启动完成后,我们可以查看 /dev/spidev0.0 设备是否存在,从而判断驱动是否正常加载。

root@igkboard:~#  ls /dev/spidev*
/dev/spidev0.0

2.8.2.2 SPI的回环测试

在嵌入式Linux环境中,我们通常需要确认 SPI 通信接口是否正常,此时通常会短接 SPI 接口的 MOSI 和 MISO 两个引脚,然后进行数据收发的回环测试。如果发送的数据与收到的数据一致,则说明 SPI 接口通信正常,否则说明 SPI 接口通信失败。

要在IGKBoard开发板上做 SPI 的回环测试实验,则需要找到开发板上SPI1的MISO和MOSI管脚,并使用杜邦线或跳线帽短接起来,如下图所示。

spi_loop_hwconn.png

我们的IGKBoard开发板的系统中已经安装好了spidev-test,所以可以使用该命令行工具进行回环测试,下面我们开始看看该命令的帮助信息:

root@igkboard:~# spidev_test -h
spidev_test: invalid option -- 'h'
Usage: spidev_test [-DsbdlHOLC3vpNR24SI]
  -D --device   device to use (default /dev/spidev1.1)
  -s --speed    max speed (Hz)
  -d --delay    delay (usec)
  -b --bpw      bits per word
  -i --input    input data from a file (e.g. "test.bin")
  -o --output   output data to a file (e.g. "results.bin")
  -l --loop     loopback
  -H --cpha     clock phase
  -O --cpol     clock polarity
  -L --lsb      least significant bit first
  -C --cs-high  chip select active high
  -3 --3wire    SI/SO signals shared
  -v --verbose  Verbose (show tx buffer)
  -p            Send data (e.g. "1234\xde\xad")
  -N --no-cs    no chip select
  -R --ready    slave pulls low to pause
  -2 --dual     dual transfer
  -4 --quad     quad transfer
  -8 --octal    octal transfer
  -S --size     transfer size
  -I --iter     iterations

这个工具使用时候,很多选项都是由缺省值的,比如默认指定的设备是spidev1.1 ,对于回环测试我们需要知道几个必须的参数

  • -D 指定spi设备节点

  • -s 设置spi传输速率,可以测试回环测试中最大传输速度

  • -v 打开发送接收回显,用于查看详细数据发送接收情况

  • -l 直接进行回环测试

  • -p 指定发送数据

接下来,我们将 MOSI 和 MISO 短接后,执行 spidev_test 测试命令如下,我们可以看到收发的数据一致,则说明 SPI 接口通信正常。

root@igkboard:~# spidev_test -D /dev/spidev0.0 -v 
spi mode: 0x4
bits per word: 8
max speed: 500000 Hz (500 kHz)
TX | FF FF FF FF FF FF 40 00 00 00 00 95 FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF F0 0D  |......@.........................|
RX | FF FF FF FF FF FF 40 00 00 00 00 95 FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF F0 0D  |......@.........................|

另外,也可以设置传输速率并自定义传输的数据。

root@igkboard:~# spidev_test -D /dev/spidev0.0 -s 10000000  -v -p 123\qwe\$%^\...
spi mode: 0x4
bits per word: 8
max speed: 10000000 Hz (10000 kHz)
TX | 31 32 33 71 77 65 24 25 5E 2E 2E 2E __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __  |123qwe$%^...|
RX | 31 32 33 71 77 65 24 25 5E 2E 2E 2E __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __  |123qwe$%^...|

接下来断开 MOSI 和 MISO 的杜邦线连接,再做 SPI 的回环测试发现收发数据不一致,则说明 SPI 通信失败。

root@igkboard:~# spidev_test -D /dev/spidev0.0 -v 
spi mode: 0x4
bits per word: 8
max speed: 500000 Hz (500 kHz)
TX | FF FF FF FF FF FF 40 00 00 00 00 95 FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF F0 0D  |......@.........................|
RX | 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  |................................|

2.8.3 SPI 接口编程

2.8.4.1 SPI相关数据结构

应用程序空间需要从spi设备传输数据时候,每组数据元素就是 struct spi_ioc_transfer 结构体类型,该结构体定义如下:

//Linux内核源码: include/uapi/linux/spi/spidev.h 
//应用编程头文件: /usr/include/linux/spi/spi/spidev.h
struct spi_ioc_transfer {
	__u64		tx_buf;  //发送数据缓存
	__u64		rx_buf;  //接收数据缓存

	__u32		len;	//数据长度
	__u32		speed_hz; //通讯速率

	__u16		delay_usecs; //两个spi_ioc_transfer之间的延时,微秒
	__u8		bits_per_word; //数据长度
	__u8		cs_change;  //取消选中片选
	__u8		tx_nbits;  //单次数据宽度(多数据线模式)
	__u8		rx_nbits;  //单次数据宽度(多数据线模式)
	__u8		word_delay_usecs;
	__u8		pad;

	/* If the contents of 'struct spi_ioc_transfer' ever change
	 * incompatibly, then the ioctl number (currently 0) must change;
	 * ioctls with constant size fields get a bit more in the way of
	 * error checking than ones (like this) where that field varies.
	 *
	 * NOTE: struct layout is the same in 64bit and 32bit userspace.
	 */
};

在编写应用程序时还需要使用ioctl函数设置spi相关配置,其函数原型如下

 #include <sys/ioctl.h>
 int ioctl(int fd, unsigned long request, ...);

其中对于SPI设备request的值常用的有以下几种

SPI_IOC_RD_MODE

设置读取SPI模式

SPI_IOC_WR_MODE

设置写入SPI模式

SPI_IOC_RD_LSB_FIRST

设置SPI读取数据模式(LSB先行返回1)

SPI_IOC_WR_LSB_FIRST

设置SPI写入数据模式。(0:MSB,非0:LSB)

SPI_IOC_RD_BITS_PER_WORD

设置SPI读取设备的字长

SPI_IOC_WR_BITS_PER_WORD

设置SPI写入设备的字长

SPI_IOC_RD_MAX_SPEED_HZ

设置读取SPI设备的最大通信频率。

SPI_IOC_WR_MAX_SPEED_HZ

设置写入SPI设备的最大通信速率

SPI_IOC_MESSAGE(N)

一次进行双向/多次读写操作

2.8.4.2 测试程序应用编程

使用上述系统调用,编写 SPI 的回环测试测试程序如下。

guowenxue@ubuntu20:~/igkboard/apps$ vim spi_test.c
/*********************************************************************************
 *      Copyright:  (C) 2024 LingYun IoT System Studio
 *                  All rights reserved.
 *
 *       Filename:  spi_test.c
 *    Description:  This file is SPI loop test program
 *
 *        Version:  1.0.0(05/23/2024)
 *         Author:  Guo Wenxue <guowenxue@gmail.com>
 *      ChangeLog:  1, Release initial version on "05/23/2024 07:51:06 PM"
 *
 ********************************************************************************/

#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <unistd.h>
#include <string.h>
#include <errno.h>
#include <getopt.h>
#include <libgen.h>
#include <fcntl.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/ioctl.h>
#include <linux/spi/spidev.h>

typedef struct spi_ctx_s
{
    int         fd;
    char        dev[64];
    uint8_t     bits;
    uint16_t    delay;
    uint32_t    mode;
    uint32_t    speed;
} spi_ctx_t;

static int spi_init(spi_ctx_t *spi_ctx);
static int transfer(spi_ctx_t *spi_ctx, uint8_t const *tx, uint8_t const *rx, size_t len);

static void program_usage(char *progname)
{
    printf("Usage: %s [OPTION]...\n", progname);
    printf(" %s is a program to test IGKBoard loop spi\n", progname);

    printf("\nMandatory arguments to long options are mandatory for short options too:\n");
    printf(" -d[device  ]  Specify SPI device, such as: /dev/spidev0.0\n");
    printf(" -s[speed   ]  max speed (Hz), such as: -s 500000\n");
    printf(" -p[print   ]  Send data (such as: -p 1234/xde/xad)\n");

    printf("\n%s version 1.0\n", progname);
    return;
}

int main(int argc,char * argv[])
{
    spi_ctx_t          spi_ctx;
    uint32_t           spi_speed = 500000;  // Default SPI speed 500KHz
    char              *spi_dev = "/dev/spidev0.0"; // Default SPI device
    char              *input_tx = "Hello LingYun"; // Default transfer data
    uint8_t            rx_buffer[128];
    int                opt;
    char               *progname=NULL;

    struct option long_options[] = {
        {"device", required_argument, NULL, 'd'},
        {"speed", required_argument, NULL, 's'},
        {"print", required_argument, NULL, 'p'},
        {"help", no_argument, NULL, 'h'},
        {NULL, 0, NULL, 0}
    };

    progname = (char *)basename(argv[0]);

    while((opt = getopt_long(argc, argv, "d:s:p:h", long_options, NULL)) != -1)
    {
        switch (opt)
        {
        case 'd':
            spi_dev = optarg;
            break;

        case 's':
            spi_speed = atoi(optarg);
            break;

        case 'p':
            input_tx = optarg;
            break;

        case 'h':
            program_usage(progname);
            return 0;

        default:
            break;
        }
    }

    memset(&spi_ctx, 0, sizeof(spi_ctx));
    strncpy(spi_ctx.dev, spi_dev, sizeof(spi_ctx.dev));
    spi_ctx.bits = 8;
    spi_ctx.delay = 100;
    spi_ctx.mode = SPI_MODE_2;
    spi_ctx.speed = spi_speed;

    if( spi_init(&spi_ctx) < 0 )
    {
        printf("Initial SPI device '%s' failed.\n", spi_ctx.dev);
        return -1;
    }
    printf("Initial SPI device '%s' okay.\n", spi_ctx.dev);

    if ( transfer(&spi_ctx, input_tx, rx_buffer, strlen(input_tx)) < 0 )
    {
        printf("spi transfer error\n");
        return -2;
    }

    printf("tx_buffer: | %s |\n", input_tx);
    printf("rx_buffer: | %s |\n", rx_buffer);

    return 0;
}

int transfer(spi_ctx_t *spi_ctx, uint8_t const *tx, uint8_t const *rx, size_t len)
{
    struct spi_ioc_transfer tr = {
        .tx_buf = (unsigned long )tx,
        .rx_buf = (unsigned long )rx,
        .len = len,
        .delay_usecs = spi_ctx->delay,
        .speed_hz = spi_ctx->speed,
        .bits_per_word = spi_ctx->bits,
    };

    if( ioctl(spi_ctx->fd, SPI_IOC_MESSAGE(1), &tr) < 0)
    {
        printf("ERROR: SPI transfer failure: %s\n ", strerror(errno));
        return -1;
    }

    return 0;
}

int spi_init(spi_ctx_t *spi_ctx)
{
    int ret;
    spi_ctx->fd = open(spi_ctx->dev, O_RDWR);
    if(spi_ctx->fd < 0)
    {
        printf("open %s error\n", spi_ctx->dev);
        return -1;
    }

    ret = ioctl(spi_ctx->fd, SPI_IOC_RD_MODE, &spi_ctx->mode);
    if( ret < 0 )
    {
        printf("ERROR: SPI set SPI_IOC_RD_MODE [0x%x] failure: %s\n ", spi_ctx->mode, strerror(errno));
        goto cleanup;
    }

    ret = ioctl(spi_ctx->fd, SPI_IOC_WR_MODE, &spi_ctx->mode);
    if( ret < 0 )
    {
        printf("ERROR: SPI set SPI_IOC_WR_MODE [0x%x] failure: %s\n ", spi_ctx->mode, strerror(errno));
        goto cleanup;
    }

    ret = ioctl(spi_ctx->fd, SPI_IOC_RD_BITS_PER_WORD, &spi_ctx->bits);
    if( ret < 0 )
    {
        printf("ERROR: SPI set SPI_IOC_RD_BITS_PER_WORD [%d] failure: %s\n ", spi_ctx->bits, strerror(errno));
        goto cleanup;
    }
    ret = ioctl(spi_ctx->fd, SPI_IOC_WR_BITS_PER_WORD, &spi_ctx->bits);
    if( ret < 0 )
    {
        printf("ERROR: SPI set SPI_IOC_WR_BITS_PER_WORD [%d] failure: %s\n ", spi_ctx->bits, strerror(errno));
        goto cleanup;
    }

    ret = ioctl(spi_ctx->fd, SPI_IOC_WR_MAX_SPEED_HZ, &spi_ctx->speed);
    if( ret == -1)
    {
        printf("ERROR: SPI set SPI_IOC_WR_MAX_SPEED_HZ [%d] failure: %s\n ", spi_ctx->speed, strerror(errno));
        goto cleanup;
    }
    ret = ioctl(spi_ctx->fd, SPI_IOC_RD_MAX_SPEED_HZ, &spi_ctx->speed);
    if( ret == -1)
    {
        printf("ERROR: SPI set SPI_IOC_RD_MAX_SPEED_HZ [%d] failure: %s\n ", spi_ctx->speed, strerror(errno));
        goto cleanup;
    }

    printf("spi mode: 0x%x\n", spi_ctx->mode);
    printf("bits per word: %d\n", spi_ctx->bits);
    printf("max speed: %d Hz (%d KHz)\n", spi_ctx->speed, spi_ctx->speed / 1000);

   return spi_ctx->fd;

cleanup:
   close(spi_ctx->fd);
   return -1;
}

编写Makefile如下

guowenxue@ubuntu20:~/igkboard/apps$ vim Makefile
CC=/opt/buildroot/cortexA7/bin/arm-linux-gcc
APP_NAME=spi_loop_test

all:clean
	@${CC} ${APP_NAME}.c -o ${APP_NAME}

clean:
	@rm -f ${APP_NAME}

install:
	cp ${APP_NAME} ~/linux/tftp/

2.8.4.3 交叉编译运行测试

程序编写好之后,接下来我们再次修改 makefile 文件,添加 spi_test 程序的编译支持。

guowenxue@ubuntu20:~/igkboard/apps$ vim makefile 
... ...
all:
        ... ...
        ${CC} spi_test.c -o spi_test

clean:
        ... ...
        @rm -f spi_test

然后使用 make 命令来交叉编译程序。

guowenxue@ubuntu20:~/igkboard/apps$ make

现在我们在开发板上通过scp 命令 或其它方式将编译生成的测试程序下载到开发板上。

root@igkboard:~# scp -P 2288 guowenxue@192.168.2.2:~/igkboard/apps/spi_test .
guowenxue@192.168.2.2's password: 
spi_test                                        100%   13KB   1.0MB/s   00:00 

接下来,将 SPI 接口的 MOSI 和 MISO 用杜邦线短接,执行测试程序可以看到回环测试成功。

root@igkboard:~# chmod a+x spi_test

root@igkboard:~# ./spi_test 
spi mode: 0x0
bits per word: 8
max speed: 500000 Hz (500 KHz)
Initial SPI device '/dev/spidev0.0' okay.
tx_buffer: | Hello LingYun |
rx_buffer: | Hello LingYun |

也可以使用 -h选项查看用法,不带任何选项参数,程序使用默认参数和数据进行发送,默认参数设备是**/dev/spidev0.0**,速率是500KHz,发送的数据是Hello LingYun,通过检查rx和rx的buffer相同,证明数据回环发送成功。

2.9 TTY串口编程

版权声明

本文档所有内容文字资料由凌云实验室郭工编著,主要用于凌云嵌入式Linux教学内部使用,版权归属作者个人所有。任何媒体、网站、或个人未经本人协议授权不得转载、链接、转帖或以其他方式复制发布/发表。已经授权的媒体、网站,在下载使用时必须注明来源,违者本人将依法追究责任。

  • Copyright (C) 2022 凌云物网智科实验室·郭工

  • Author: Guo Wenxue Email: guowenxue@gmail.com QQ: 281143292

wechat_pub

2.9.1 串口通信介绍

2.9.1.1 串行接口介绍

串口通信在早期是计算机与外界通信的主要手段,那时候的计算机基本上都标配有串口以实现和外部通信。它在计算机发展的早期就定义了一套标准的串口规约,DB9接口(2排9个引脚)就是其标准接口,此外还有不常见的DB25(25个引脚)。

uart_db9

大家注意串口DB9是9Pin的接头,不要跟我们PC上用来接显示器、投影仪等图形化设备的VGA接口(3排15个引脚)搞混淆了。

uart_vga

因为串行通信比较简单,它在嵌入式领域使用得非常广泛,如我们经常使用的通信模块GPRS/3G/4G/5G/NB-IoT、GPS、串口WiFi、蓝牙、Zigbee、LoRa等等几乎全部都是使用串口通信,这也就导致熟练掌握串口通信是嵌入式开发人员的必备技能之一。而现如今PC上基本上很少提供该接口,这样在嵌入式开发过程中,基本人手都会准备一个USB转串口设备来扩展使用。

uart_converter

DB9接口中有9根通信线,其中3根线(GND、TXD、RXD)很重要必不可少,剩余6根都是和流控有关的,现代我们使用串口都是用来做调试一般都禁用流控,所以这6根很少使用。

uart_9pin

2.9.1.2 串口通信原理

串口通信连线

任何通信都要有信息传输的载体,或者是有线的或者是无线的。串口通信是通过串口线进行有线通信,在通信时最少需要两根线(GND和信号线)即可实现单工通信,GPS模块就是典型的串口单工通信实例。此外大部分的串口通信都使用3根线(TXD、RXD、GND)来实现全双工通信。

uart_hwconn

串口通信时序

串口通信时,收发是一个周期一个周期进行的,每个周期传输n个二进制位。这一个周期就叫做一个通信单元,一个通信单元由:起始位+数据位+奇偶校验位+停止位组成的。

uart_databits

波特率

串口通信是一种异步通信方式,收发双方并没有同步时钟信号来规约一个bit的数据发送电平维持多长时间,这样只能靠收发双方的速率来同步收发数据,这个速率叫做波特率(BaudRate),其单位为bps(bit per second)。

串口通信常用速率为115200(3G/4G/调试串口等)、9600(NB-IoT/GPS等)、4800等。收发双方的速率必须保持一致,否则会出现乱码或完全接收不到的现象。

起始位

它表示发送方要开始发送一个通信单元,起始位的定义是串口通信标准事先指定的,是由通信线上的电平变化来反映的。对于串口通信而言总线没有数据传输空闲时维持高电平,一旦产生一个下降沿变成低电平则表示起始信号。

数据位

它一个通信单元中发送的有效信息位,是本次通信真正要发送的有效数据,串口通信一次发送多少位有效数据是可以设定的(可选的有6、7、8、9,一般都是选择8位数据位,因为一般通过串口发送的数据都是以字节为单位的ASCII码编码,而ASCII码中一个字符刚好编码为8位)。

校验位

它是用来校验数据位,以防止数据位出错的。这里有两种校验方式,即奇校验和偶校验。其中:

  • 奇校验保证传输过程中1的个数为奇数,如8位数据传输中1的个数为偶数,则校验位为1;

  • 奇校验保证传输过程中1的个数为偶数,如8位数据传输中1的个数为偶数,则校验位为0;

停止位

它是发送方用来表示本通信单元结束标志的,停止位的定义是串口通信标准事先指定的,是由通信线上的电平变化来反映的。常见的有1位停止位、1.5位停止位、2位停止位等,一般使用的是1位停止位。

2.9.1.3 TTL与RS232电平

电平信号是用信号线电平减去参考线(GND)电平得到的电压差,这个电压差决定了传输值是1还是0。在电平信号中多少V代表1,多少V代表0是不固定的,取决于电平标准。

  • TTL是MUC或芯片电平,高电平为5V(51单片机)或3.3V(ARM处理器)表示1,低电平表示0;

  • RS232电平是用-15V ~ -3V表示1,+3V ~ +15V表示0,注意电平的定义反相了一次;

TTL是芯片上的串口直接出的电平,它适合距离近且干扰小的情况,如开发板上芯片与芯片之间、开发板与串口模块之间的短距离串口通信。设备与设备之间的长距离通信,因为压降和信号干扰等原因通常会使用RS232来进行通信,但它的通信距离也一般小于15米。

对于软件编程来说,RS232电平传输和TTL电平传输是没有差异的,所以电平标准对硬件工程更有意义,当然软件工程师也要了解,这样在连接设备调试才不会混接。串口通信接口DB9或插针上并不能区分当前是RS232还是TTL电平,这需要向模块、产品供应方确认,或查看产品手册甚至开发板原理图来确定。

CPU或芯片引出的串口默认都是TTL电平,如果是RS232电平的话一般会接一个MAX232的芯片。如下图所示:

uart_rs232

2.9.1.4 串行通信总结

串口通信是异步通信,所以通信双方必须事先约定好通信参数,这些通信参数包括:波特率、数据位、校验位、停止位,这些参数中的任何一个设置错误,都会导致通信失败。譬如波特率调错了,发送方发送没问题,接收方也能接收,但是接收到全是乱码。

  • 串口通信单向只有一根数据线,也就意味着同一时刻一个方向上只能传输1个bit的数据,所以是串行通信;

  • 串口通信的发送方和接收方之间没有统一的时钟信号,所以它是异步通信方式;

  • 串口通信既可以实现全双工通信(Txd、Rxd、Gnd三线),也可以实现单工通信(Txd/Rxd、Gnd两线);

  • 串口通信出现时间较早、速率较低,并且采样电平信号传输,抗干扰能力不强,传输的距离较近;

现在我们的PC基本上都没有串口接口了,在我们调试单片机的过程中又需要使用串口来进行程序调试,这样不可避免需要购买USB转串口设备。那么在购买USB转串口设备时,我们又要注意哪些事项呢?

  • 如果对方设备是DB9接头,则要根据对方提供的是公头还是母头,来购买相应的接口类型;

  • 要根据对方设备是RS232电平还是TTL电平来购买相应的USB转串口设备;

  • 如果对方开发板上提供的是插针或公头,则可以使用公头接头+杜邦线来连接;

  • USB转串口设备的价格差别主要是芯片和晶振好坏,10元左右的串口线误码率和丢包率极高;

  • USB转串口芯片一般有 CH34x、PL2303、CP210x、FT232等,稳定性和价格由低到高;

  • 不同的USB转串口设备用的芯片可能不一样,那安装的驱动也应该不一样;uart_converter2

uart_converter2

2.9.1.5 RS485总线介绍

RS-485是美国电子工业协会(EIA)在1983年批准了一个新的平衡传输标准,EIA一开始将RS(Recommended Standard)做为标准的前缀,不过后来为了便于识别标准的来源,已将RS改为EIA/TIA。目前标准名称为TIA-485,但工程师及应用指南仍继续使用RS-485来称呼此标准。

现在常用的数据接口协议有很多种,每种协议都是针对特定的应用开的,具有特定的协议规范和结构。接口包括RS-232、RS-485/RS-422、CAN、I2C、SPI、I2S、LIN和SMBus等。其中,RS-485和RS-422仍然是最可靠的协议之一,特别适合工厂和楼宇自动化等恶劣的工业电气环境。

工业和楼宇自动化应用中最常见的问题之一是在快速切换电感负载、静电放电以及工厂自动化设备运转过程中频繁的电压浪涌,会产生较大的电气特性瞬变,进而破坏数据传输或造成物理网络损坏。RS-485标准提供的接口可承受恶劣环境。

当需要多个总线主机/驱动器时,RS-485具有更高的灵活性。该标准是在RS-422的基础上进行改进,将设备数量从10个提高到了32个,拥有更宽的共模和差分电压范围,确保在最大负载下具有足够的信号电压。拥有这种增强的多点功能后,用户可构建连接到单个RS-485串口的设备网络。较强的抗噪性和多点功能使RS-485成为工业应用中的首选串行链路,可将多个分布式设备通过网络连接到PC或者其它控制器,实现数据采集、HMI等类似操作。RS-485是RS-422的扩展,因此所有RS-422设备均可通过RS-485进行控制。

uart_rs485_bus

RS - 485 与RS - 422的典型应用相类似: 过程自动化( 化工、酿造、造纸厂) 、工厂自动化( 汽车和金属制造)、HVAC、安防、电机控制和运动控制。由于RS-485提高了灵活性,所以在两者中更常见。

RS485和RS232一样都是基于串口的通讯接口,数据收发的操作是一致的,但是它们在实际应用中通讯模式却有着很大的区别,RS232接口为全双工数据通讯模式,而RS485接口一般为半双工数据通讯模式,数据的收发不能同时进行,为了保证数据收发的不冲突,硬件上是通过方向切换来实现的,相应也要求软件上必须将收发的过程严格地分开。

RS-232-C接口标准出现较早,这就难免有不足之处,主要有以下几点:

  • 接口使用一根Tx信号线和一根Rx信号线而构成共地的传输形式,这种方式抗噪声抗干扰性弱;

  • 传输距离有限,最大传输距离标准值为50英尺,实际上也只能用在50米左右。

  • 传输速率较低,在异步传输时,波特率为20Kbps(一般是115200bps);

  • 通信的时候只能两点之间进行通信,不能够实现多机联网通信;

  • RS232与TTL电平不兼容,另外接口的信号电平值较高,易损坏接口电路的芯片;

    针对 RS232 接口的不足,就不断出现了一些新的接口标准,RS485 就是其中之一,它具备以下的特点:

  • 差分传输增加噪声抗扰度,减少噪声辐射;

  • 长距离链路,最长可达4000英尺(约1219米);

  • 数据速率高达10Mbps(40英寸内,约12.2米);

  • 同一总线可以连接多个驱动器和接收器

  • 宽共模范围允许驱动器和接收器之间存在地电位差异,允许最大共模电压-7-12V

    RS-485能够进行远距离传输主要得益于使用差分信号进行传输,当有噪声干扰时仍可以使用线路上两者差值进行判断,使传输数据不受噪声干扰。

uart_rs485_1

RS-485差分线路包括以下2个信号。也可能会有第3个信号,为了平衡线路正常动作要求所有平衡线路上有一个共同参考点,称为SC或者G。该信号可以限制接收端收到的共模信号,收发器会以此信号作为基准值来测量AB线路上的电压。

  • A:非反向(non-inverting)信号

  • B:反向(inverting)信号

RS485有两线制和四线制两种接线,四线制是全双工通讯方式,两线制是半双工通讯方式。RS485采用差分信号负逻辑,+2V~+6V表示“0”,- 6V~- 2V表示“1”:

  • 若是SPACE(逻辑0),线路A信号电压比线路B高;

  • 若是MARK(逻辑1),线路B信号电压比线路A高;

不同的IC使用的信号标示方式不同,不过EIA的标准中只使用A和B的名称。数据为1时,信号B会比信号A要高。不过因为标准其中也提到信号A是“非反向信号”,信号B是“反向信号”。因此信号A、B的定义就更容易混淆了,许多组件制造商(错误的)依循了这个A/B的命名原则,所以具体定义需要实际参考设计厂家芯片手册。

为了不引起分歧,一种常用的命名方式是:

  • TX+ / RX+ 或D+来代替B(信号1时为高电平)

  • TX- / RX- 或D-来代替A(信号0时为低电平)

下图列出在RS-485利用“异步开始-停止”方式发送一个字符(0xD3,MSB)时,D+端子及 D−端子上的电压变化。

uart_rs485_2

如下图所示,这是凌云实验室 ISKBoard单片机开发板上的 RS485 接口原理图。在 GM3085E 模块左侧连接的是 MCU 的一路 UART串口,同时还有两外一个 RS485_DIR 的方向引脚,该引脚主要是用来控制 RS485 的读写方向控制。而右侧出的则是 RS485 的差分信号。

uart_rs485_sch

由此可知,我们在对 RS485 进行软件编程时与 UART 串口编程方式是一模一样的,只是在后者的基础上需要加上方向位的控制而已。另外有些开发板(如本文档所描述的 IGKBoard-IMX6ULL)上的 RS485的读写方向位由通信的数据自动控制,这样其与 UART 串口编程一模一样了。

2.9.2 串口回环测试

2.9.2.1 驱动配置使用说明

在IGKBoard开发板上的 40Pin 扩展引脚上,提供了 UART2、UART3、UART4、UART7 这4路串口。在Linux系统启动时如果没有配置使能这些串口,它们将会默认工作在GPIO 功能模式。

40pin_uart

如果想使能40Pin上的 UART 串口通信引脚的话,我们需要修改开发板上的DTOverlay配置文件,添加它们的支持。具体方法为修改 eMMC/TF卡 启动介质的 boot 分区下的 config.txt 文件,修改 dtoverlay_uart 选项中添加相应串口的支持即可。如下所示,我们添加了这四个串口的支持,当然也可以只支持所需要使用的串口。

root@igkboard:~# vim /run/media/mmcblk1p1/config.txt 

# Enable UART overlays
dtoverlay_uart=2 3 4 7

root@igkboard:~# sync

如果系统默认没有挂载这个分区,则需要先使用 mount 命令来手动挂载。

root@igkboard:~# mount | grep mmcblk
/dev/mmcblk1p2 on / type ext4 (rw,relatime)

root@igkboard:~# mkdir -p /run/media/mmcblk1p1/
root@igkboard:~# mount /dev/mmcblk1p1 /run/media/mmcblk1p1/

修改完成后重启系统,系统启动时将会自动加载 UART 设备。待系统启动完成后,我们可以查看 /dev/ttymxcX 设备是否存在,从而判断驱动是否正常加载。

root@igkboard:~# ls /dev/ttymxc*
/dev/ttymxc0  /dev/ttymxc1  /dev/ttymxc2  /dev/ttymxc3  /dev/ttymxc6

这些串口的对应关系为:

  • /dev/ttymxc0 为硬件上的 UART1,该串口为开发板的调试登录串口,连接到了 USB 转串口芯片上;

  • /dev/ttymxc1 为硬件上的 UART2,即 40Pin 上的 #8(UART2_TX) 和 #10(UART2_RX);

  • /dev/ttymxc2 为硬件上的 UART3,即 40Pin 上的 #11(UART3_TX) 和 #12(UART3_RX);

  • /dev/ttymxc3 为硬件上的 UART4,即 40Pin 上的 #13(UART4_TX) 和 #15(UART4_RX);

  • /dev/ttymxc6 为硬件上的 UART7,即 40Pin 上的 #16(UART7_TX) 和 #18(UART7_RX);

注: 在Linux系统下,一般CPU出来的串口设备名叫 ttySx,如第一个串口叫 ttyS0,第二个串口叫ttyS1…,而 USB 转串口设备叫 ttyUSBx,如第一个USB转串口设备叫 ttyUSB0,第二个USB转串口设备叫 ttyUSB1…, 而 NXP i.MX系列的串口设备名叫 ttymxcX,如第一个串口叫 ttymxc0,第二个串口设备叫 ttymxc1…

2.9.2.2 串口回环测试

这里我们使用杜邦线将 40Pin 引脚上的#8(UART2_TX) 和 #10(UART2_RX) 短接,这样就可以在UART2串口(/dev/ttymxc1) 上做软件上的回环收发测试。

uart_loopback

在嵌入式环境中,我们经常会使用 busybox 命令提供的 microcom 程序来调试串口,下面是该命令的使用方法。

root@igkboard:~# microcom --help
BusyBox v1.36.1 () multi-call binary.

Usage: microcom [-d DELAY_MS] [-t TIMEOUT_MS ] [-s SPEED] [-X] TTY

这里我们使用 -s 11500 选项指定串口通信波特率为 115200 bps。因为这里是回环测试,所以同一个UART的收发双方的波特率始终一致,所以这里使用任意一个波特率都应该能够正常通信。

root@igkboard:~# microcom -s 115200 /dev/ttymxc1
Hello, LingYun IoT System Studio!

关于该命令的使用需要注意的几点:

  • microcom 命令像Linux系统输入密码时一样不会回显,上面我们输入的字符串之所以打印出来,这是因为短接了 Txd 和 Rxd 引脚,发送出去的字符串口设备又收到了,所以这里会打印一次,否则每个字符应该打印两次;

  • microcom 执行完成之后,按 ctrl+x 组合键退出,而不是 ctrl+c

接下来,我们在 UART3 上执行 microcom 命令来做收发测试,这时我们发现在该串口终端上随便输入字符时就没有任何打印。这是因为该命令既没有回显,也没有环回短接 Txd 和 Rxd,所以这里就不会由任何输出了。

root@igkboard:~# microcom -s 115200 /dev/ttymxc2

另外,我们可以使用 stty 命令来查看串口的相关配置等。

root@igkboard:~# stty -F /dev/ttymxc1
speed 9600 baud; line = 0;
-brkint -imaxbel

root@igkboard:~# stty -a -F /dev/ttymxc1
speed 115200 baud; rows 0; columns 0; line = 0;
intr = ^C; quit = ^\; erase = ^?; kill = ^U; eof = ^D; eol = <undef>; eol2 = <undef>; swtch = <undef>;
start = ^Q; stop = ^S; susp = ^Z; rprnt = ^R; werase = ^W; lnext = ^V; discard = ^O; min = 1; time = 0;
-parenb -parodd -cmspar cs8 hupcl -cstopb cread clocal -crtscts
-ignbrk -brkint -ignpar -parmrk -inpck -istrip -inlcr -igncr icrnl ixon -ixoff -iuclc -ixany -imaxbel -iutf8
opost -olcuc -ocrnl onlcr -onocr -onlret -ofill -ofdel nl0 cr0 tab0 bs0 vt0 ff0
isig icanon iexten echo echoe echok -echonl -noflsh -xcase -tostop -echoprt echoctl echoke -flusho -extproc

也可以使用该命令修改串口的默认波特率等。

root@igkboard:~# stty -F /dev/ttymxc1 115200 cs8 -cstopb -parenb

root@igkboard:~# stty -F /dev/ttymxc1 
speed 115200 baud; line = 0;
-brkint -imaxbel
  • 115200:设置波特率为115200

  • cs8:每个字符8个比特

  • -cstopb:1个停止位

  • -parenb:无奇偶校验

接下来,我们可以使用 echo 命令来通过串口发送数据。

root@igkboard:~# echo -e "Hello, LingYun IoT System Studio" > /dev/ttymxc1

2.9.2.3 busybox介绍

在嵌入式Linux系统领域,大家不得不了解 busybox 这个工具。BusyBox 是一个非常流行的软件工具,它将许多 Unix 工具的功能整合到一个单一的可执行文件中。它被称为 “Linux 工具箱” 或 “嵌入式 Linux 的瑞士军刀”。以下是 BusyBox 的详细介绍:

  • 多功能性:BusyBox 集成了许多常见的 Unix 工具,如 lscpmvcatgreptarvi 等,全部包含在一个单独的可执行文件中。用户可以通过不同的命令名链接(symlinks)或命令行选项来调用这些功能。

  • 轻量级:BusyBox 的设计目标是轻量和高效,非常适合资源受限的环境,如嵌入式系统。它的二进制文件通常比 GNU Core Utilities 等工具包要小得多。

  • 可配置性:通过配置文件,可以定制 BusyBox 以包含或排除特定的功能,进一步减小其大小和资源消耗。

下面是 busybox 命令的帮助信息,从这里我们可以看到当前 busybox 程序里提供了哪些 Linux 命令。

root@igkboard:~# busybox
BusyBox v1.36.1 () multi-call binary.
BusyBox is copyrighted by many authors between 1998-2015.
Licensed under GPLv2. See source distribution for detailed
copyright notices.

Usage: busybox [function [arguments]...]
   or: busybox --list
   or: function [arguments]...

        BusyBox is a multi-call binary that combines many common Unix
        utilities into a single executable.  Most people will create a
        link to busybox for each function they wish to use and BusyBox
        will act like whatever it was invoked as.

Currently defined functions:
        [, [[, addgroup, adduser, ascii, ash, awk, base32, basename, blkid,
        bunzip2, bzcat, bzip2, cat, chattr, chgrp, chmod, chown, chroot, chvt,
        clear, cmp, cp, cpio, crc32, cut, date, dc, dd, deallocvt, delgroup,
        deluser, depmod, df, diff, dirname, dmesg, dnsdomainname, du, dumpkmap,
        dumpleases, echo, egrep, env, expr, false, fbset, fdisk, fgrep, find,
        flock, free, fsck, fstrim, ftpget, fuser, getopt, getty, grep, groups,
        gunzip, gzip, head, hexdump, hostname, hwclock, id, ifconfig, ifdown,
        ifup, insmod, ip, kill, killall, klogd, less, ln, loadfont, loadkmap,
        logger, logname, logread, losetup, ls, lsmod, lzcat, md5sum, mesg,
        microcom, mkdir, mkfifo, mknod, mkswap, mktemp, modprobe, more, mount,
        mountpoint, mv, nc, netstat, nohup, nproc, nslookup, od, openvt, patch,
        pgrep, pidof, pivot_root, printf, ps, pwd, rdate, readlink, realpath,
        reboot, renice, reset, resize, rev, rfkill, rm, rmdir, rmmod, route,
        run-parts, sed, seq, setconsole, setsid, sh, sha1sum, sha256sum, shuf,
        sleep, sort, start-stop-daemon, stat, strings, stty, sulogin, swapoff,
        swapon, switch_root, sync, sysctl, syslogd, tail, tar, tee, telnet,
        test, tftp, time, top, touch, tr, true, ts, tty, udhcpc, udhcpd,
        umount, uname, uniq, unlink, unzip, uptime, users, usleep, vi, watch,
        wc, wget, which, who, whoami, xargs, xzcat, yes, zcat

如果想要执行 busybox 提供的某条命令,可以带上这个命令作为参数即可。

root@igkboard:~# busybox ls /
bin         etc         lost+found  proc        sbin        tmp
boot        home        media       root        srv         usr
dev         lib         mnt         run         sys         var

另外每次执行 busybox 里的某条命令时都还要带上 busybox 前缀会比较麻烦,这样我们可以在系统 bin 路径下创建该命令的符号链接指向 busybox 即可。如下所示,前面我们所使用的 microcom 就是一个指向 busybox 程序的符号链接,这样我们使用该命令时,直接输入 microcom 就可以了。

root@igkboard:~# which microcom
/usr/bin/microcom

root@igkboard:~# ls -l /usr/bin/microcom
lrwxrwxrwx 1 root root 19 Mar  9  2018 /usr/bin/microcom -> /bin/busybox.nosuid

root@igkboard:~# microcom
BusyBox v1.36.1 () multi-call binary.

Usage: microcom [-d DELAY_MS] [-t TIMEOUT_MS ] [-s SPEED] [-X] TTY

2.9.3 TTY串口编程

2.9.3.1 TTY设备介绍

在Linux系统中,”tty” 是 “teletypewriter” 的缩写,它是指代终端设备,也就是用户与系统进行交互的接口。在早期的计算机系统中,”teletypewriter” 是一种用于输入和输出文本的设备,现在在Linux中,这个概念已经扩展到了各种终端设备,包括串口设备、图形终端和虚拟终端等,这样 TTY 则对应着虚拟终端。

在Linux系统中,TTY终端设备主要分为三种类型:串口终端(/dev/ttyS*)、虚拟终端(/dev/tty*)和控制台终端(/dev/console)。在Linux系统的 /dev 路径下可以看到很多和 tty 相关的设备节点,这些节点的总结如下:

设备节点

含义

/dev/ttyX

/dev/tty0 代表前台程序的终端,/dev/tty 代表自己所使用的终端,剩余的从 /dev/tty1 开始的 /dev/ttyX 分别代表一个虚拟终端

/dev/pts/X

这类设备节点是伪终端对应的设备节点,伪终端对应的设备节点都在 /dev/pts 目录下,以数字编号命名,通过 ssh 或者 telnet 这些远程登录协议登录到开发板,那么开发板就会在 /dev/pts 目录下生成一个设备节点

/dev/ttymxcX

i.MX处理器上的UART的串口终端,以此命名

/dev/console

通过内核的配置可以指定 console 是哪一个 tty 设备, 一般使用 UART0 对应的tty设备(如ttymxc0)作为console;

注:表格中的 X 代表数字编号

下面是我们在IGKBoard-IMX6ULL 开发板上查看的 /dev 路径下的 tty 设备。

tty_dev

由此可见,Linux下的串口本质上是属于 tty 上设备,所以要想学习Linux下串口编程则需要了解Linux下的 tty 设备编程。下图是Linux 内核里的 tty子系统驱动框架图,它主要包含 tty核心(TTY Core)、tty行规程(TTY Line Disipline)和tty驱动(TTY Driver)三个部分。

  • tty核心是对整个tty设备的抽象,对用户提供统一的接口;

  • tty行规程用于处理控制字符、回显输入数据、缓存输入数据、显示数据输出等。行规程可以根据应用层的需求进行设置,如果应用层不需要这些处理机制,可以将其设置为原始模式;

  • tty驱动则是面向tty设备的硬件驱动;

tty_framework

在TTY驱动框架中,数据的收发传输过程是一个涉及多个层次和组件的复杂过程。这里以应用程序需要与串口设备进行通信,讲解它们的大致流程。

tty_syscall

  1. **打开设备文件:**应用程序首先通过open()系统调用打开与串口设备对应的设备文件,例如/dev/ttymxc1。这个操作会触发TTY驱动框架的相应处理,将应用程序与特定的TTY设备关联起来。

  2. **配置串口参数:**接下来,应用程序可能会调用ioctl()等系统调用API来配置串口的参数,如波特率、数据位、停止位和校验位等。这些配置参数会传递给TTY驱动框架,以确保数据的正确传输。

  3. **发送数据:**一旦串口配置完成,应用程序就可以通过write()系统调用向串口发送数据。这些数据首先被写入到TTY驱动框架的发送缓冲区中。然后,TTY驱动框架会按照配置好的串口参数,将数据格式化并发送到实际的串口硬件。在发送过程中,TTY驱动框架会处理如奇偶校验、停止位等细节。

  4. **接收数据:**当串口接收到数据时,TTY驱动框架会读取这些数据,并将其放入接收缓冲区中,然后它会检查数据的完整性(例如,是否包含正确的奇偶校验位)。如果数据完整无误,TTY驱动框架会通过中断或其他机制通知应用程序有数据可读,应用程序随后可以通过read系统调用从接收缓冲区中读取数据。

  5. **错误处理:**在整个传输过程中,TTY驱动框架还会处理可能出现的错误情况,如串口通信中断、硬件故障等。当发生错误时,TTY驱动框架会采取相应的措施,如通知应用程序、重试发送或关闭串口等。

2.9.3.2 TTY编程API

Linux系统将所有设备都抽象成了文件,这样所有的设备编程都是下面这个流程:

  1. 调用 open() 系统调用打开相应的设备;

  2. 调用 write() 系统调用发送数据;

  3. 调用 read() 系统调用接收数据;

  4. 调用 close() 系统调用关闭打开的设备;

因为串口设备是一个特殊的设备的,在使用它之前需要配置波特率、奇偶校验、数据位、停止位等信息。这样我们在open() 打开串口设备之后,还需要调用 ioctl() 系统调用对这些参数进行配置后,才能进行后续的读写操作。Linux系统为上层配置串口参数做了一套标志的 API 封装,这样我们可以直接使用这套API 来配置串口设备。

termios 结构体

串口设备的应用编程内容包含两个方面,读写和配置,而对于配置而言最重要的结果体就是这个 termios 结构体,该结构体定义如下:

struct termios
{
    tcflag_t c_iflag; 	/* input mode flags */
    tcflag_t c_oflag; 	/* output mode flags */
    tcflag_t c_cflag; 	/* control mode flags */
    tcflag_t c_lflag; 	/* local mode flags */
    cc_t c_line; 		/* line discipline */
    cc_t c_cc[NCCS];	/* control characters */
    speed_t c_ispeed; 	/* input speed */
    speed_t c_ospeed; 	/* output speed */
};

c_iflag 输入模式控制输入数据在被传递给应用程序之前的处理方式

含义

IGNBRK

忽略输入终止条件

BRKINT

当检测到输入终止条件时发送 SIGINT 信号

IGNPAR

忽略帧错误和奇偶校验错误

PARMRK

对奇偶校验错误做出标记

INPCK

对接收到的数据执行奇偶校验

ISTRIP

将所有接收到的数据裁剪为 7 比特位,也就是去除第八位

c_oflag 输出模式控制字符的处理方式,也就是由应用程序发出去的字符在传递到串口之前是如何处理的

含义

OPOST

启用输出处理功能,如果不设置该标志则其他标志都被忽略

OLCUC

将输出字符中的大写字符转换成小写字符

ONOCR

在第 0 列不输出回车符

ONLRET

不输出回车符

OFILL

发送填充字符以提供延时

c_cflag 控制模式控制终端设备的硬件特性,例如对于串口而言该字段可以设置串口波特率,数据位,校验位,停止位等硬件特性,在一些系统中也可以使用 c_ispeed 和 c_ospeed 这两个成员来指定串口的波特率

含义

B4800

4800 波特率

B9600

9600 波特率

B19200

19200 波特率

B38400

38400 波特率

B57600

57600 波特率

B115200

115200 波特率

CS5

5 个数据位

CS6

6 个数据位

CS7

7 个数据位

CS8

8 个数据位

CSTOPB

2 个停止位,如果不设置该标志则默认是一个停止位

CREAD

接收使能

PARENB

使能奇偶校验

PARODD

使用奇校验、而不是偶校验

c_lflag 本地模式用于控制终端的本地数据处理和工作模式

含义

ISIG

若收到信号字符,则会产生相应的信号

ICANON

启用规范模式

ECHO

启用输入字符的本地回显功能,当我们在终端输入字符的时候,字符会显示出来,这就是回显功能

ECHOE

若设置 ICANON,则允许退格操作

ECHOK

若设置 ICANON,则 KILL 字符会删除当前行

ECHONL

若设置 ICANON,则允许回显换行符

ECHOPRT

若设置 ICANON 和 IECHO,则删除字符和被删除的字符都会被显示

ECHOKE

若设置 ICANON,则允许回显在 ECHOE 和 ECHOPRT 中设定的 KILL字符

TOSTOP

若一个后台进程试图向它的控制终端进行写操作,则系统向该后台进程的进程组发送 SIGTTOU 信号

IEXTEN

启用输入处理功能

c_cc 特殊控制字符是一些字符组合,例如 ctrl + c 或者 ctrl + z,当用户键入这样的组合键终端采取特殊处理的方式

含义

VTIME

非规范模式下, 指定读取的每个字符之间的超时时间(以分秒为单位) TIME

VMIN

在非规范模式下,指定最少读取的字符数 MIN

终端的三种工作模式

工作模式(Work Modes)指的是终端设备在接收和发送数据时的操作模式。在Unix-like系统中,终端模式通常分为三种:规范模式(Canonical Mode)、非规范模式(Non-canonical Mode)和原始模式(Raw Mode)。通过设置 c_lflag 设置 ICANNON 标志来定义终端是以规范模式还是非规范模式工作,默认为规范模式。

  1. 规范模式(Canonical Mode):

    • 在规范模式下输入是行缓冲,并且只有在输入碰到行结束符时才会被传递给程序。这就是为什么在规范模式下按下回车键(Enter)才会导致输入的实际传递给程序的原因。

    • 因为有缓冲,所以输入可以使用退格(Backspace)和删除(Delete)键进行编辑。

    • 适用于需要按行输入和处理的情况,例如命令行交互。

  2. 非规范模式(Non-canonical Mode):

    • 在非规范模式下,输入不会被缓冲,每个字符立即传递给程序。这种模式下,输入的处理更为及时。

    • 适用于需要实时处理每个输入字符的情况,如实时图形界面、游戏等。

  3. 原始模式(Raw Mode):

    • 是一种特殊的非规范模式,所有的输入数据以字节为单位被处理,即有一个字节输入时,触发输入有效,但是终端不可回显,并且禁用终端输入和输出字符的所有特殊处理

    • 适用于需要最小化输入延迟的情况,例如对输入响应时间要求非常高的应用程序。 我们实际应用过程中更多的是原始模式!

在非规范模式下,对参数 MIN(c_cc[VMIN])和 TIME(c_cc[VTIME])的设置决定 read 函数的调用方式,MIN 和 TIME 的取值不同,会有以下四种不同的情况

MIN

TIME

说明

= 0

= 0

read 调用总是会立即返回,若有可读数据,则读数据并返回被读取的字节数,否则读取不到数据返回 0

> 0

= 0

read 函数会被阻塞,直到有 MIN 个字符可以读取时,read 才返回,返回值为读取的字节数

= 0

> 0

只要有数据可读或者经过 TIME 个十分之一秒的时间,read立即返回,返回为读取的字节数

> 0

> 0

当有 MIN 个字节可读或者两个输入字符之间的时间间隔超过 TIME 个十分之一秒,read 才返回,因为在输入第一个字符后系统才会启动定时器,所以,read 至少读取一个字节后才返回

2.9.3.3 串口测试编程

接下来我们编程实现串口的回环测试代码 ttyS_test 代码如下,详细的编程API说明及示例代码可以点击此链接参考学习。

guowenxue@ubuntu20:~/linux-api/source$ vim ttyS_test.c
/*********************************************************************************
 *      Copyright:  (C) 2024 LingYun IoT System Studio
 *                  All rights reserved.
 *
 *       Filename:  ttyS_test.c
 *    Description:  This file is comport loop back test program
 *
 *        Version:  1.0.0(05/24/2024)
 *         Author:  Guo Wenxue <guowenxue@gmail.com>
 *      ChangeLog:  1, Release initial version on "05/24/2024 07:38:43 PM"
 *
 ********************************************************************************/

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <termios.h>
#include <pthread.h>

#define DEV_NAME    "/dev/ttymxc1"
#define MSG         "Hello, LingYun IoT System Studio!"

int main(int argc, char **argv)
{
    struct termios      tty;
    int                 serial_fd;
    int                 bytes;
    char                read_buf[64];

    /*+-------------------------+
      |    Open Serial Port     |
      +-------------------------+*/
    serial_fd = open(DEV_NAME, O_RDWR );
    if (serial_fd == -1)
    {
        printf("Open '%s' failed: %s\n", DEV_NAME, strerror(errno));
        return 0;
    }

    /*+-------------------------+
      |  Configure Serial Port  |
      +-------------------------+*/

    tcgetattr(serial_fd, &tty);// read current serial port settings

    tty.c_cflag &= ~PARENB;    // Clear parity bit, disabling parity (most common)
    tty.c_cflag &= ~CSTOPB;    // Clear stop field, only one stop bit used in communication (most common)
    tty.c_cflag &= ~CSIZE;     // Clear all bits that set the data size
    tty.c_cflag |= CS8;        // 8 bits per byte (most common)
    tty.c_cflag &= ~CRTSCTS;   // Disable RTS/CTS hardware flow control (most common)
    tty.c_cflag |= CREAD | CLOCAL; // Turn on READ & ignore ctrl lines (CLOCAL = 1)

    tty.c_lflag &= ~ICANON;
    tty.c_lflag &= ~ECHO;      // Disable echo
    tty.c_lflag &= ~ECHOE;     // Disable erasure
    tty.c_lflag &= ~ECHONL;    // Disable new-line echo
    tty.c_lflag &= ~ISIG;      // Disable interpretation of INTR, QUIT and SUSP
    tty.c_iflag &= ~(IXON | IXOFF | IXANY); // Turn off s/w flow ctrl
    tty.c_iflag &= ~(IGNBRK|BRKINT|PARMRK|ISTRIP|INLCR|IGNCR|ICRNL); // Disable any special handling of received bytes

    tty.c_oflag &= ~OPOST;     // Prevent special interpretation of output bytes (e.g. newline chars)
    tty.c_oflag &= ~ONLCR;     // Prevent conversion of newline to carriage return/line feed

    tty.c_cc[VTIME] = 10;      // Wait for up to 1s (10 deciseconds), returning as soon as any data is received.
    tty.c_cc[VMIN] = 0;

    /* Set in/out baud rate to be 115200 */
    cfsetispeed(&tty, B115200);
    cfsetospeed(&tty, B115200);

    tcsetattr(serial_fd, TCSANOW, &tty);

    /*+-------------------------+
      |   Write to Serial Port  |
      +-------------------------+*/
    write(serial_fd, MSG, strlen(MSG));
    printf("Send MSG    : %s\n", MSG);

    /*+-------------------------+
      |  Read from Serial Port  |
      +-------------------------+*/
    /*  
     * The behaviour of read() (e.g. does it block?, how long does it block for?)
     * depends on the configuration settings above, specifically VMIN and VTIME
     */
    memset(&read_buf, '\0', sizeof(read_buf));
    bytes =  read(serial_fd, &read_buf, sizeof(read_buf));
    if (bytes< 0)
    {   
        printf("Error reading: %s", strerror(errno));
        goto cleanup;
    }   

    printf("Received MSG: %s\n", read_buf);

    /*+-------------------------+
      |   close Serial Port     |
      +-------------------------+*/

cleanup:
    close(serial_fd);
    return 0; // success
}

2.9.4.3 交叉编译运行测试

程序编写好之后,接下来我们再次修改 makefile 文件,添加 ttyS_test 程序的编译支持。

guowenxue@ubuntu20:~/igkboard/apps$ vim makefile 
... ...
all:
        ... ...
        ${CC} ttyS_test.c -o ttyS_test -lpthread

clean:
        ... ...
        @rm -f ttyS_test

然后使用 make 命令来交叉编译程序。

guowenxue@ubuntu20:~/igkboard/apps$ make

现在我们在开发板上通过scp 命令 或其它方式将编译生成的测试程序下载到开发板上。

root@igkboard:~# scp -P 2288 guowenxue@192.168.2.2:~/igkboard/apps/ttyS_test .
guowenxue@192.168.2.2's password: 
ttyS_test                                        100% 8644   919.4KB/s   00:00

接下来将 UART2 串口的 TXD(#8) 和 RXD(#10) 引脚用杜邦线短接,执行测试程序可以看到收发的数据一致,。

root@igkboard:~# chmod a+x ttyS_test

root@igkboard:~# ./ttyS_test                   
Send MSG    : Hello, LingYun IoT System Studio!
Received MSG: Hello, LingYun IoT System Studio!

而如果将杜邦线断开,则程序运行的结果如下:

root@igkboard:~# ./ttyS_test      
Send MSG    : Hello, LingYun IoT System Studio!
Received MSG: 

2.10 CAN接口编程

版权声明

本文档所有内容文字资料由凌云实验室郭工编著,主要用于凌云嵌入式Linux教学内部使用,版权归属作者个人所有。任何媒体、网站、或个人未经本人协议授权不得转载、链接、转帖或以其他方式复制发布/发表。已经授权的媒体、网站,在下载使用时必须注明来源,违者本人将依法追究责任。

  • Copyright (C) 2022 凌云物网智科实验室·郭工

  • Author: Guo Wenxue Email: guowenxue@gmail.com QQ: 281143292

wechat_pub

2.10.1 CAN总线介绍

2.10.1.1 汽车电子领域介绍

汽车电子产业是汽车产业链上游重要的零部件制造环节,随着自动驾驶、无人驾驶技术及新的信息化技术在汽车上的应用,汽车电子市场一直持续增长。受益于汽车工业电动化、智能化、网联化,以及汽车电子在整车中的成本占比快速上升等多重利好因素,汽车电子单车成本占比不断提升,国内外市场均保持高速增长。 全球市场规模在2028年有望超过4000亿美元,国内市场预计2026年接近万亿人民币。

can_bus

汽车电子目前是非常值得关注和学习的热点产业之一,最近几年秋招(包括社招)中汽车电子相关岗位需求非常旺盛,薪资水平自然也水涨船高,下面是 BOSS直聘上武汉的相关岗位需求信息。

jobs_can.jpg

IGKBoard开发板为满足汽车电子领域入门学习所需,板载了2路CAN总线(汽车电子)和一路RS485总线(充电桩),这样为深入学习该行业相关知识提供了先决硬件条件。另外,我们也将会以相关行业经验,带领大家一起学习汽车电子领域的专业知识。

charging_pile

2.10.1.2 CAN总线介绍

CAN 是 Controller Area Network 的缩写(以下称为 CAN),是 ISO国际标准化的串行通信协议。 它最初出现在80年代末的汽车工业中,由德国 Bosch 公司最先提出。当时,由于消费者对于汽车功能的要求越来越多,而这些功能的实现大多是基于电子操作的,这就使得电子装置之间的通讯越来越复杂,同时意味着需要更多的连接信号线。提出 CAN 总线的最初动机就是为了解决现代汽车中庞大的电子控制装置之间的通讯,减少不断增加的信号线。于是,他们设计了一个单一的网络总线,所有的外围器件可以被挂接在该总线上。

can_car

1993年,CAN 已成为国际标准 ISO11898(高速应用)和 ISO11519(低速应用)。 CAN 是一种多主方式的串行通讯总线,基本设计规范要求有高的位速率,高抗电磁干扰性,而且能够检测出产生的任何错误。当信号传输距离达到10Km 时,CAN 仍可提供高达50Kbit/s 的数据传输速率。由于 CAN 总线具有很高的实时性能,现在,CAN 的高性能和可靠性已被认同,并被广泛地应用于工业自动化、船舶、医疗设备、工业设备等方面。

CAN通讯协议标准(ISO-11898:2003)介绍了设备间信息是如何传递以及符合开放系统互联参考模型(OSI)的哪些分层项。实际CAN通讯是在连接设备的物理介质中进行,物理介质的特性由模型中的物理层定义。

can_iso

ISO11898体系结构定义七层,OSI模型中的最低两层作为数据链路层和物理层,如下图所示:

can_arch

  • LLC用于接收滤波、超载通告、回复管理;

  • MAC用于数据封装/拆封、帧编码、媒体访问管理、错误检测与标定、应答、串转发/并转串;

  • PLS用于位编码/解码、位定时、同步;

  • PMA为收发器特性。

2.10.1.3 CAN总线特点

CAN总线具有以下特点:

  • 符合OSI开放式通信系统参考模型;

  • 两线式总线结构,电气信号为差分式;

  • 多主控制,在总线空闲时,所有的单元都可开始发送消息,最先访问总线的单元可获得发送权;多个单元同时开始发送时,发送高优先级ID消息的单元可获得发送权;

  • 点对点控制,一点对多点及全局广播几种传送方式接收数据,网络上的节点可分成不同的优先级,可以满足不同的实时要求;

  • 采用非破坏性位仲裁总线结构机制,当两个节点同时向网络上传送信息时,优先级低的节点主动停止数据发送,而优先级高的节点可不受影响地继续传送数据

  • 消息报文不包含源地址或者目标地址,仅通过标识符表明消息功能和优先级;

  • 基于固定消息格式的广播式总线系统,短帧结构;

  • 事件触发型,只有当有消息要发送时,节点才向总线上广播消息;

  • 可以通过发送远程帧请求其它节点发送数据;

  • 消息数据长度0~8Byte;

  • 节点数最多可达110个;

  • 错误检测功能。所有节点均可检测错误,检测处错误的单元会立即通知其它所有单元;

  • 发送消息出错后,节点会自动重发;

  • 故障限制,具有自动关闭总线的功能,节点控制器可以判断错误是暂时的数据错误还是持续性错误,当总线上发生持续数据错误时,控制器可将节点从总线上隔离,以使总线上的其他操作不受影响;

  • 通信介质可采用双绞线、同轴电缆和光导纤维,一般使用最便宜的双绞线;

  • 理论上,CAN总线用单根信号线就可以通信,但还是配备了第二根导线,第二根导线与第一根导线信号为差分关系,可以有效抑制电磁干扰;

  • 直接通信距离最远可达10KM(速率4Kbps以下),通信速率最高可达1MB/s(此时距离最长40M);

  • 总线上可同时连接多个节点,可连接节点总数理论上是没有限制的,但实际可连接节点数受总线上时间延迟及电气负载的限制。

  • 每帧信息都有CRC校验及其他检错措施,数据错误率极低;

  • 废除了传统的站地址编码,取而代之的是对通信数据块进行编码。采用这种方法的优点是可使网络内的节点个数在理论上不受限制,数据块的标识码可由11位或29位二进制数组成,因此可以定义211或229个不同的数据块,这种数据块编码方式,还可使不同的节点同时接收到相同的数据,这一点在分步式控制中非常重要。

CAN总线具体以下优势:

can_advantage

2.10.1.4 CAN总线结构

CAN节点通常由三部分组成:CAN收发器、CAN控制器和MCU。目前,我们常用的STM32、华大、瑞萨等单片机内部就集成了CAN控制器外设,通过配置就可实现对CAN报文数据的读取和发送。

CAN总线通过差分信号进行数据传输,CAN收发器将差分信号转换为TTL电平信号,或者将TTL电平信号转换为差分信号,CAN控制器将TTL电平信号接收并传输给MCU,如下图所示:

cal_ttl

CAN总线是一种广播类型的总线,可支持线形拓扑、星形拓扑、树形拓扑和环形拓扑等。CAN网络中至少需要两个节点设备才可进行通信,无法仅向某一个特定节点设备发送消息,发送数据时所有节点都不可避免地接收所有流量。但是,CAN总线硬件支持本地过滤,因此每个节点可以设置对有效的消息做出反应。

线形拓扑

线形拓扑是在一条主干总线分出各个节点支线,其优点在于布线施工简单,接线方便,阻抗匹配规则固定,缺点是拓扑不够灵活,在一定程度上影响通讯距离,如下图所示:

can_top

星形拓扑

星形拓扑是每个节点通过中央设备连到一起,其优点是容易扩展,缺点是一旦中央设备出故障会导致总线集体故障,而且分支线长不同,阻抗匹配复杂,可能需要通过一些中继器或集线器进行扩展,如下图所示:

can_top

树形拓扑

树形拓扑是节点分支比较多,且分支长度不同,其优点是布线方便,缺点是网络拓扑复杂,阻抗匹配困难,通讯中极易出现问题,必须加一些集线器设备,如下图所示:

can_top

环形拓扑

环形拓扑是将CAN总线头尾相连,形成环状,其优点是线缆任意位置断开,总线都不会出现问题,缺点是信号反射严重,无法用于高波特率和远距离传输,如下图所示:

can_top

虽然CAN总线可以支持多种网络拓扑,但在实际应用中比较推荐使用线形拓扑,且在IOS 11898-2中高速CAN物理层规范推荐也是线形拓扑。在ISO 11898-2和ISO 11898-3中分别规定了两种CAN总线结构(在BOSCH CAN2.0规范中,并没有关于总线拓扑结构的说明)。

  • ISO 11898-2中定义了通信速率为125Kbps~1Mbps的高速闭环CAN通信标准,当通信总线长度≤40米,最大通信速率可达到1Mbps,高速闭环CAN(高速CAN)。

  • ISO 11898-3中定义了通信速率为10~125Kbps的低速开环CAN通信标准,当传输速率为40Kbps时,总线距离可达到1000米。

2.10.1.5 CAN学习参考

关于更多CAN总线的基础知识,请参考下面的博客和视频学习。

2.10.2 CAN总线回环测试

2.10.2.1 驱动配置使用说明

在IGKBoard开发板上的 40Pin 扩展引脚上,提供了连个路 CAN 接口。在Linux系统启动时如果没有配置使能这些串口,它们将会默认工作在GPIO 功能模式。

40pin_can

如果想使能40Pin上的 CAN 通信引脚的话,我们需要修改开发板上的DTOverlay配置文件,添加它们的支持。具体方法为修改 eMMC/TF卡 启动介质的 boot 分区下的 config.txt 文件,修改 dtoverlay_can 选项中添加相应CAN接口的支持即可。如下所示,我们添加了这两路CAN口的支持。

root@igkboard:~# vim /run/media/mmcblk1p1/config.txt 

# Enable CAN overlays
dtoverlay_can=1 2

root@igkboard:~# sync

如果系统默认没有挂载这个分区,则需要先使用 mount 命令来手动挂载。

root@igkboard:~# mount | grep mmcblk
/dev/mmcblk1p2 on / type ext4 (rw,relatime)

root@igkboard:~# mkdir -p /run/media/mmcblk1p1/
root@igkboard:~# mount /dev/mmcblk1p1 /run/media/mmcblk1p1/

修改完成后重启系统,系统启动时将会自动加载 CAN 设备。待系统启动完成后,我们可以 使用ifconfig 命令查看 CAN 设备是否存在,从而判断驱动是否正常加载。

root@igkboard:~# ifconfig -a
can0: flags=128<NOARP>  mtu 16
        unspec 00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00  txqueuelen 10  (UNSPEC)
        RX packets 0  bytes 0 (0.0 B)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 0  bytes 0 (0.0 B)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0
        device interrupt 207  

can1: flags=128<NOARP>  mtu 16
        unspec 00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00  txqueuelen 10  (UNSPEC)
        RX packets 0  bytes 0 (0.0 B)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 0  bytes 0 (0.0 B)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0
        device interrupt 208  
... ...        

需要注意的是,开发板上的CAN接口并没有带 CAN 的电平转换芯片,这样如果想在开发板上做 CAN 通信,还需要额外购买 官方GW通信扩展板(CAN,485,232)CAN电平转换模块(TJA1050 )。另外,如果想要深入学习CAN总线通信,推荐购买 USB转CAN协议分析仪(支持模拟仿真)

这里,我们使用 TJA1050 模块连接开发板上的两路 CAN 接口,需要注意的是 can 电平转模块也需要5V供电,其回环测试硬件连接实物图如下所示。

can_loopback

2.10.3.2 Linux下CAN命令

SocketCAN 是 Linux 内核对 CAN 总线的一种抽象,它提供了与传统网络协议栈相同的接口,使得 CAN 应用程序开发更加方便。SocketCAN 支持多种 CAN 硬件接口,并提供了多种工具来管理和调试 CAN 网络。

查询CAN信息

查询 can 的详细信息,包括波特率,标志设置等信息。

root@igkboard:~# ip -details link show can0
2: can0: <NOARP,ECHO> mtu 16 qdisc noop state DOWN mode DEFAULT group default qlen 10
    link/can  promiscuity 0  allmulti 0 minmtu 0 maxmtu 0 
    can state STOPPED (berr-counter tx 0 rx 0) restart-ms 0 
          flexcan: tseg1 4..16 tseg2 2..8 sjw 1..4 brp 1..256 brp_inc 1
          clock 40000000 numtxqueues 1 numrxqueues 1 gso_max_size 65536 gso_max_segs 65535 tso_max_size 65536 tso_max_segs 65535 gro_max_size 65536 parentbus platform parentdev 2090000.can 

查询 can 的工作状态

root@igkboard:~# ip -details -statistics link show can0
2: can0: <NOARP,ECHO> mtu 16 qdisc noop state DOWN mode DEFAULT group default qlen 10
    link/can  promiscuity 0  allmulti 0 minmtu 0 maxmtu 0 
    can state STOPPED (berr-counter tx 0 rx 0) restart-ms 0 
          flexcan: tseg1 4..16 tseg2 2..8 sjw 1..4 brp 1..256 brp_inc 1
          clock 40000000 
          re-started bus-errors arbit-lost error-warn error-pass bus-off
          0          0          0          0          0          0         numtxqueues 1 numrxqueues 1 gso_max_size 65536 gso_max_segs 65535 tso_max_size 65536 tso_max_segs 65535 gro_max_size 65536 parentbus platform parentdev 2090000.can 
    RX:  bytes packets errors dropped  missed   mcast           
             0       0      0       0       0       0 
    TX:  bytes packets errors dropped carrier collsns           
             0       0      0       0       0       0 

查询 can 的收发数据包情况,以及中断号

root@igkboard:~# ifconfig can0
can0: flags=128<NOARP>  mtu 16
        unspec 00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00  txqueuelen 10  (UNSPEC)
        RX packets 0  bytes 0 (0.0 B)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 0  bytes 0 (0.0 B)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0
        device interrupt 207  

设置CAN参数

can 参数设置详解

root@igkboard:~# ip link set can0 type can --help
can: unknown option "--help"
Usage: ip link set DEVICE type can
        [ bitrate BITRATE [ sample-point SAMPLE-POINT] ] |
        [ tq TQ prop-seg PROP_SEG phase-seg1 PHASE-SEG1
          phase-seg2 PHASE-SEG2 [ sjw SJW ] ]

        [ dbitrate BITRATE [ dsample-point SAMPLE-POINT] ] |
        [ dtq TQ dprop-seg PROP_SEG dphase-seg1 PHASE-SEG1
          dphase-seg2 PHASE-SEG2 [ dsjw SJW ] ]
        [ tdcv TDCV tdco TDCO tdcf TDCF ]

        [ loopback { on | off } ]
        [ listen-only { on | off } ]
        [ triple-sampling { on | off } ]
        [ one-shot { on | off } ]
        [ berr-reporting { on | off } ]
        [ fd { on | off } ]
        [ fd-non-iso { on | off } ]
        [ presume-ack { on | off } ]
        [ cc-len8-dlc { on | off } ]
        [ tdc-mode { auto | manual | off } ]

        [ restart-ms TIME-MS ]
        [ restart ]

        [ termination { 0..65535 } ]

        Where: BITRATE  := { NUMBER in bps }
                  SAMPLE-POINT  := { 0.000..0.999 }
                  TQ            := { NUMBER in ns }
                  PROP-SEG      := { NUMBER in tq }
                  PHASE-SEG1    := { NUMBER in tq }
                  PHASE-SEG2    := { NUMBER in tq }
                  SJW           := { NUMBER in tq }
                  TDCV          := { NUMBER in tc }
                  TDCO          := { NUMBER in tc }
                  TDCF          := { NUMBER in tc }
                  RESTART-MS    := { 0 | NUMBER in ms }

设置 can0 的波特率为 800kbps,can 网络波特率最大值为 1mbps。

root@igkboard:~# ip link set can0 up type can bitrate 800000

设置回环模式,自发自收,用于测试是硬件是否正常,loopback 不一定支持

root@igkboard:~# ip link set can0 up type can bitrate 800000 loopback on

使能禁用CAN

我们使用 ifconfigip 命令来使能、关闭 CAN 接口。需要注意的是,在使能 CAN 接口之前必须要先设置速率,否则在使能 CAN 接口的时候会出现 “SIOCSIFFLAGS: Invalid argument” 错误:

root@igkboard:~# ifconfig can0 up
SIOCSIFFLAGS: Invalid argument

而设置速率之后再使能则成功。

root@igkboard:~# ip link set can0 up type can bitrate 800000
root@igkboard:~# ifconfig can0 up

像网卡一样,我们也可以使用 ifconfig 命令关闭 CAN 接口。

root@igkboard:~# ifconfig can0 down

也可以使用 ip 命令使能、禁用 CAN 设备。

root@igkboard:~# ip link set can0 down
root@igkboard:~# ip link set can0 up type can

CAN收发数据

发送默认 id 为 0x1 的 can 标准帧,数据为 0x11 22 33 44 55 66 77 88 每次最大 8 个 byte

root@igkboard:~# cansend can0 0x11 0x22 0x33 0x44 0x55 0x66 0x77 0x88
interface = can0, family = 29, type = 3, proto = 1

-e 表示扩展帧,can_id 最大 29bit,标准帧 CAN_ID 最大 11bit,-I 表示 can_id

root@igkboard:~# cansend can0 -i 0x800 0x11 0x22 0x33 0x44 0x55 0x66 0x77 0x88 -e
interface = can0, family = 29, type = 3, proto = 1

–loop 表示发送 20 个包

root@igkboard:~# cansend can0 -i 0x02 0x11 0x12 --loop=20
interface = can0, family = 29, type = 3, proto = 1

接收数据

root@igkboard:~# candump can0

发送数据,123 是发送到的 can 设备 id 号,后面接发送内容

root@igkboard:~# cansend can0 123#1122334455667788

2.10.3.3 CAN回环测试

如下图所示,将开发板的 CAN_RX 连接收发器的 RX,CAN_TX 连接收发器的 TX。然后将两个CAN模块收发器的 CANH 连 CANH,CANL 连 CAL,电源 VCC 根据模块供电要求连到 5V 引脚上。

can_loopback

使用 ip 命令对两路 can 进行参数配置,配置先必须先禁用改设备

root@igkboard:~# ip link set can0 down
root@igkboard:~# ip link set can0 type can bitrate 500000
root@igkboard:~# ip link set can0 up

root@igkboard:~# ip link set can1 down
root@igkboard:~# ip link set can1 type can bitrate 500000
root@igkboard:~# ip link set can1 up

使用 ifconfig 命令查看两个 CAN 接口是否使能。

root@igkboard:~# ifconfig can0
can0: flags=193<UP,RUNNING,NOARP>  mtu 16
        unspec 00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00  txqueuelen 10  (UNSPEC)
        RX packets 0  bytes 0 (0.0 B)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 0  bytes 0 (0.0 B)
        TX errors 0  dropped 1 overruns 0  carrier 1  collisions 0
        device interrupt 207  

root@igkboard:~# ifconfig can1
can1: flags=193<UP,RUNNING,NOARP>  mtu 16
        unspec 00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00  txqueuelen 10  (UNSPEC)
        RX packets 0  bytes 0 (0.0 B)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 0  bytes 0 (0.0 B)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0
        device interrupt 208  

接下来开两个SSH连接端口,一个使用 candump 命令运行在 can0 上来接收数据。

root@igkboard:~# candump can0

另外一个SSH登录端口运行 cansend 命令运行在 can1 接口上发送数据。

root@igkboard:~# cansend can1 123#01020304050607

这时 candump 监听的 can0 接口上将会收到 can1 接口发过来的数据,反之也可以从 can1 上收到 can0 发过来的数据。

root@igkboard:~# candump can0
  can0  123   [7]  01 02 03 04 05 06 07

2.10.3.4 CAN测试应用编程

Linux下的 SocketCAN 提供了一种标准化的方式来在 Linux 系统上处理 CAN 通信。通过使用标准的套接字接口和内置的工具,开发人员可以方便地配置、管理和调试 CAN 网络。无论是简单的 CAN 报文传输,还是复杂的 CAN 网络管理,SocketCAN 都提供了强大的支持。

下面这个程序同时提供 CAN 的发送和接收功能,通过该程序我们可以了解Linux下的 CAN 接口编程函数。

guowenxue@ubuntu20:~/linux-api/source$ vim can_test.c 
/*********************************************************************************
 *      Copyright:  (C) 2024 LingYun IoT System Studio
 *                  All rights reserved.
 *
 *       Filename:  can_test.c
 *    Description:  This file is socket CAN loop test program
 *
 *        Version:  1.0.0(05/26/2024)
 *         Author:  Guo Wenxue <guowenxue@gmail.com>
 *      ChangeLog:  1, Release initial version on "05/26/2024 05:42:49 PM"
 *
 ********************************************************************************/

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/ioctl.h>
#include <net/if.h>
#include <sys/socket.h>
#include <linux/can.h>
#include <linux/can/raw.h>
#include <getopt.h>

// 打印使用帮助信息
void print_usage(const char *progname)
{
    printf("Usage: %s -i <can_interface> -m <mode>\n", progname);
    printf("Options:\n");
    printf("  -i, --interface    CAN interface (e.g., can0)\n");
    printf("  -m, --mode         Mode: send or receive\n");
    printf("  -h, --help         Display this help message\n");
}

void send_can_message(const char *ifname)
{
    int                     fd;
    struct sockaddr_can     addr;
    struct ifreq            ifr;
    struct can_frame        frame;

    // 创建socket
    fd = socket(PF_CAN, SOCK_RAW, CAN_RAW);
    if (fd < 0)
    {
        perror("socket");
        exit(1);
    }

    // 指定CAN接口
    strcpy(ifr.ifr_name, ifname);
    ioctl(fd, SIOCGIFINDEX, &ifr);
    addr.can_family = AF_CAN;
    addr.can_ifindex = ifr.ifr_ifindex;

    // 绑定socket到CAN接口
    if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0)
    {
        perror("bind");
        exit(1);
    }

    // 构造CAN帧
    frame.can_id = 0x123; // 设置CAN ID
    frame.can_dlc = 2;    // 数据长度
    frame.data[0] = 0x11; // 数据
    frame.data[1] = 0x22; // 数据

    // 发送CAN帧
    if (write(fd, &frame, sizeof(struct can_frame)) != sizeof(struct can_frame))
    {
        perror("write");
        exit(1);
    }

    // 关闭socket
    close(fd);
}

void receive_can_message(const char *ifname)
{
    int                     fd;
    struct sockaddr_can     addr;
    struct ifreq            ifr;
    struct can_frame        frame;

    // 创建socket
    fd = socket(PF_CAN, SOCK_RAW, CAN_RAW);
    if (fd < 0) {
        perror("socket");
        exit(1);
    }

    // 指定CAN接口
    strcpy(ifr.ifr_name, ifname);
    ioctl(fd, SIOCGIFINDEX, &ifr);
    addr.can_family = AF_CAN;
    addr.can_ifindex = ifr.ifr_ifindex;

    // 绑定socket到CAN接口
    if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0)
    {
        perror("bind");
        exit(1);
    }

    // 接收CAN帧
    while (1)
    {
        int nbytes = read(fd, &frame, sizeof(struct can_frame));
        if (nbytes < 0)
        {
            perror("read");
            exit(1);
        }

        if (nbytes < sizeof(struct can_frame))
        {
            fprintf(stderr, "read: incomplete CAN frame\n");
            exit(1);
        }

        // 打印接收到的CAN帧
        printf("Received CAN frame: ID=0x%X DLC=%d data=", frame.can_id, frame.can_dlc);
        for (int i = 0; i < frame.can_dlc; i++)
            printf("%02X ", frame.data[i]);

        printf("\n");
    }

    // 关闭socket
    close(fd);
}

int main(int argc, char **argv)
{
    int              opt, index = 0;
    const char      *ifname = NULL;
    const char      *mode = NULL;

    // 定义长选项
    static struct option long_options[] =
    {
        {"interface", required_argument, 0, 'i'},
        {"mode",      required_argument, 0, 'm'},
        {"help",      no_argument,       0, 'h'},
        {0, 0, 0, 0}
    };

    while ((opt = getopt_long(argc, argv, "i:m:h", long_options, &index)) != -1)
    {
        switch (opt) {
            case 'i':
                ifname = optarg;
                break;
            case 'm':
                mode = optarg;
                break;
            case 'h':
                print_usage(argv[0]);
                return 0;
            default:
                print_usage(argv[0]);
                return 1;
        }
    }

    if (ifname == NULL || mode == NULL)
    {
        print_usage(argv[0]);
        return 1;
    }

    if (strcmp(mode, "send") == 0)
    {
        send_can_message(ifname);
    }
    else if (strcmp(mode, "receive") == 0)
    {
        receive_can_message(ifname);
    }
    else
    {
        fprintf(stderr, "Invalid mode: %s\n", mode);
        print_usage(argv[0]);
        return 1;
    }

    return 0;
}

2.9.4.5 交叉编译运行测试

程序编写好之后,接下来我们再次修改 makefile 文件,添加 can_test 程序的编译支持。

guowenxue@ubuntu20:~/igkboard/apps$ vim makefile 
... ...
all:
        ... ...
        ${CC} can_test.c -o can_test

clean:
        ... ...
        @rm -f can_test

然后使用 make 命令来交叉编译程序。

guowenxue@ubuntu20:~/igkboard/apps$ make

现在我们在开发板上通过scp 命令 或其它方式将编译生成的测试程序下载到开发板上。

root@igkboard:~# scp -P 2288 guowenxue@192.168.2.2:~/igkboard/apps/can_test .
guowenxue@192.168.2.2's password: 
can_test                                        100% 9092   623.8KB/s   00:00

如下图所示,将开发板的 CAN_RX 连接收发器的 RX,CAN_TX 连接收发器的 TX。然后将两个CAN模块收发器的 CANH 连 CANH,CANL 连 CAL,电源 VCC 根据模块供电要求连到 5V 引脚上。

can_loopback

使用 ip 命令配置使能开发板上的两路 CAN 接口。

root@igkboard:~# ip link set can0 down
root@igkboard:~# ip link set can0 type can bitrate 500000
root@igkboard:~# ip link set can0 up

root@igkboard:~# ip link set can1 down
root@igkboard:~# ip link set can1 type can bitrate 500000
root@igkboard:~# ip link set can1 up

使用两个 SSH2 窗口登录到开发板上,在 can0 上运行接收端程序:

root@igkboard:~# ./can_test -i can0 -m receive

在 can1 上运行发送端程序:

root@igkboard:~# ./can_test -i can1 -m send

发送端程序成功执行之后,在接收端窗口上可以正常接收到 can 发送端发过来的数据。

root@igkboard:~# ./can_test -i can0 -m receive
Received CAN frame: ID=0x123 DLC=2 data=11 22
^C
root@igkboard:~#