课程链接:
【【跟小梅哥0基础学Zynq】基于C语言的Zynq SOC FPGA嵌入式裸机设计和开发教程】 https://www.bilibili.com/video/BV1Ra411q7ww/?p=2&share_source=copy_web&vd_source=d39882002c43c19fb5a68abed84dfad5
嵌入式裸机程序设计开发实验
实验一:使用GPIO点亮LED灯
课程目的:
展示基于Zynq SoC FPGA的嵌入式裸机开发流程
课程内容:
- 使用Vivado软件创建工程:7010:XC7Z010CLG400-2/7020: XC7Z020CLG400-2
- 创建包含zynq PS硬核的系统:D9PTK(MT41K128M16JT-125:K)
- 编写PS硬核的C程序
- 运行和调试
总结
- 创建Vivado工程
- 创建包含PS的bd文件
- 基于bd文件生成vivado的逻辑工程代码
- 导出包含ps的系统的硬件信息为hdf文件
- 在SDK中基于HDF文件创建板级信息文件
- 创建应用工程
- 编写C代码
- 运行
课程引出的思考问题
- 如何添加IP组件 如何修改和配置各个组件的参数 如何知道读写哪些寄存器实现怎样的功能 使用哪些函数实现功能 如何正确包含需要的头文件 如何编写程序实现功能
课程随堂工程和源码
课程大纲
1、什么是bsp(board support package)
BSP的全称为Board Support Package,中文意思是板级支持包,事实上对于Zynq来说,bsp的确切含义应该是硬件系统支持包,我们创建的BD文件添加的PS、以及添加的各种扩展IP,共同组成了基于PS的硬件系统,该硬件系统中包含的各个PS和PL的外设功能,Xilinx原厂都为用户提供了预先编写并验证好的驱动程序,以及实现各种常用功能时需要用到的函数,这些驱动程序以及常用函数组合在一起,共同实现了对该特定硬件系统的编程的支持,用户编程时,可以使用该支持包中提供的驱动和函数,来避免自己编写应用函数和基于寄存器读写的驱动。由于这些驱动程序中加了很多安全判断和兼容操作,所以,在对程序尺寸和运行效率要求不高的场合,推荐使用BSP提供的驱动和函数,而在对性能和程序尺寸有要求的场合,推荐自己编写基于寄存器读写的驱动。
2、如何实现对指定地址的读写操作
使用指针
如何实现对指定地址的读写呢,根据CPU的工作原理,每一项操作的本质,都是对指定地址进行数据的读写操作。所以从CPU编程角度来说,我们只需要知道每个寄存器的绝对地址,就可以使用指针的方式读取或写入该存储器/寄存器。例如对地址为0x00000020的寄存器进行读写,就可以使用下面的形式:
- 读寄存器:return (volatile u8 ) 0x00000020;
- 写寄存器:(volatile u8 ) 0x00000020 = 0x12;
使用IO读写函数
xilinx提供的基本的地址读写函数,这些函数位于xil_io.h文件中,如下所示:
Xil_In8(addr); Xil_In16(addr); Xil_In32(addr); Xil_In64(addr); Xil_Out8 (addr, data); Xil_Out16(addr, data); Xil_Out32(addr, data); Xil_Out64(addr, data);
如果打开任意一个函数查看,会发现这些函数就是对指针操作的封装而已。如下所示:
static INLINE u32 Xil_In32(UINTPTR Addr) { return (volatile u32 ) Addr; }
3、如何知道各个外设的硬件信息(寄存器地址,位功能)
经过上面的分析,我们了解到,CPU的编程就是对各个地址的存储器/寄存器进行读写操作 ,那么这些地址分别是多少呢?换句话说,如何知道哪个地址的数据能够对应代表什么功能,或者说,指定功能由哪个地址的存储器中的值控制呢?方法很多,最基础,最原始,也是最可靠的方法就是查看芯片的数据手册(datasheet)。
查看datasheet
这个方法对于任何的CPU架构的芯片都是通用的。不管是8051、还是Cortex-M3、以及高端的Cortex-A9、A53系列。Zynq 7000系列芯片,对应的手册叫做UG585,安装了Xilinx的文档向导(DocNav)后,就能在文档向导中找到这个文档。文档向导只要安装了vivado,就会自动被安装 ,不用再重新单独安装。在UG585的附录B中,有所有外设的每个寄存器的地址和功能描述。
使用BSP提供的驱动和硬件信息文件
在bsp工程中,Xilinx为每一个硬件功能都提供了描述其寄存器地址和位功能的.h文件,这类文件字母x开头,然后紧跟外设功能名,最后以_hw结尾。例如,对于GPIO,提供的该文件名为xgpiops_hw.h,对于串口(uart),提供的该文件名为xuartps_hw.h,对于SD/MMC外设控制器,提供的该文件名为xsdps_hw.h。
需要注意的是,SDK在生成BSP时,会仅针对系统中配置使能了的硬件生成硬件信息文件,对于没有配置使能的硬件,则可能不会生成硬件信息文件,例如我们开发流程课程中,因为没有使能SD/MMC外设和UART外设,所以在SDK中生成的LED_bsp下就找不到刚刚说的xsdps_hw.h和xuartps_hw.h。
4、如何实现程序中的延时
在程序中,少不了要进行延时处理,比如最简单的LED灯闪烁控制,就可以通过延时加翻转LED控制IO的方式来实现LED指定时长的亮灭。对精度要求很低的延时,可以使用死循环的方式实现,例如使用while、for循环等等。对精度要求较高的延时,可以使用BSP中提供的基于CPU心跳定时器的定时/延时函数,例如
微秒单位延迟:usleep(unsigned long useconds)
秒单位延迟:sleep(unsigned int seconds)
这些函数都包含在名为unistd.h的文件中。
这些延时函数在Xilinx的BSP中,都是根据CPU架构平台,使用CPU系统标配的滴答定时器实现的,所以延时精度相对与程序中写while死循环的方式延迟,要高得多。
另外,这种延时方式是跨平台通用的,在很多操作系统或者提供裸机bsp的平台上,都支持使用这些函数进行延时,所以,推荐大家在编写c程序时,需要用到延时的地方,优先使用这两个延时函数,而不是使用自己写的while死循环的方式,或者封装的其它延时函数名。
想要更高精度的延时方法,或者希望在延时期间还能让cpu做点其他事情,就可以使用定时器中断的方式了。使用这种方式,可以让指定的事件在指定的定时时间到来后去执行一次,而在没有到来的情况下,CPU可以处理别的事情。这种方式,后续我们讲解定时器的使用时候会讲到。
总结下来就是,对于精度要求不高,和对CPU利用率要求不高的场合,使用usleep、sleep函数是非常方便的,而且还能在大多数情况下跨平台移植,非常方便。
5、使用跨平台可移植的数据类型
什么是跨平台的数据类型,为什么要使用跨平台的数据类型?先给大家举个简单的例子。
8位数量类型,Xilinx的SDK提供的例子,使用的都是u8,对应的数据类型实际为unsigned char。使用u8定义一个变量i。就是 u8 i;
在Xilinx的SDK中这么定义,没问题,只要你的程序中任何一个头文件中包含了"xil_types.h"就能正常使用,比如在LED这个程序中,我们为了使用GPIO的寄存器地址信息,包含了xgpiops_hw.h这个头文件,而在xgpiops_hw.h这个文件中,又包含了"xil_types.h"这个头文件,所以我们在main函数中使用u8\u16\u32\u64\s8\s16\s32\s64来定义变量的类型,才会没有任何问题。但是当我们将这句简单的话放到其他的编译器中,就不行了,例如,我们放到intel的硬核或软核的软件开发环境,也就是Nios II 13.0 Software Build Tools for Eclipse中,就会报错。
会不会是nios ii eds比较垃圾,没有做这个定义呢,那我们再打开Cypress的FX3(USB3.0芯片)的开发环境,在这个里面我们同样来写这句话,也同样会报错。
也就是说,除了在Xilinx的SDK中,这个类型在其他很多开发环境中都没有定义。(确实是没有定义,不是我没有找到定义的头文件的位置,是确实没有定义,感兴趣的可以自己下来研究核实)所以当我们在Xilinx的SDK中写的程序移植到其他的开发环境中时,所有的变量定义都会失效,而让我们不得不自己手写该平台的这些变量类型的定义语句(使用typedef)。
那么有没有一种变量类型是在绝大多数开发环境中通用的呢?确实有,这个就是C99标准新增的标准头文件stdint.h中定义的各个类型。在stdint.h中,定义了各种常见类型的缩写关键词。
那么,这些类型定义,真的能够做到跨平台通用吗?这个确认是的,不信的话大家可以做个试验,在自己期望测试的开发环境中,写上下面这段代码。
include “stdint.h”,
uint8_t us8_type;
uint16_t us16_type;
uint32_t us32_type;
uint64_t us64_type;
int8_t s8_type;
int16_t s16_type;
int32_t s32_type;
int64_t s64_type;
然后看看能不能在你的编译环境中找到这些定义。或者修改你可以成功编译的程序里某个变量的类型声明为stdint.h中定义的类型,然后编译看看,看会不会报错。
基本上可以绝大多数开发环境都能很好的支持这些定义,所以使用stdint.h文件中定义的数据类型来定义变量,就能够实现跨平台便捷移植。推荐大家使用。
总结
好了,这节课程讲到这里,还没有讲解如何去点亮一个LED灯,只是介绍了C一些在Xilinx的SDK中编程的常见思路和规律。掌握这些规律,能够让你往具备独立开发能力的开发者的路上迈进,而不是去做一个简单的模仿者。万事俱备,下一节课,我们就来以GPIO为例,仔细看看我们该用怎么的思路和方法对各种硬件外设进行编程控制。
更多课程笔记请查看:【zynq裸机编程课程笔记合集】
http://www.corecourse.cn/forum.php?mod=viewthread&tid=29095
1、什么是硬件编程
所谓硬件编程,完整说法应该是对硬件进行编程以实现对该硬件的控制,在Zynq开发中,就是指通过对Zynq的各个硬件外设控制器(GPIO、UART、I2C、SPI等)以及在FPGA(PL)中添加的各个外设控制器IP的寄存器进行读写,以配置其工作模式,并完成数据的传输。
2、为啥要学习硬件编程
要控制外部设备执行相应的操作,如在LCD上显示字符,控制继电器断开/闭合,通过串口打印各种工作信息。或者获取外部设备的相关信息,例如读取温湿度传感器(I2C)采集的温度和湿度信息,控制并读取模数转换器(SPI)采样的模拟电压值,检测按键的按下与释放状态等。CPU要想控制或者获得某些外部设备的状态或信息,需要通过各种硬件外设控制器来与这些外部设备进行信息交互。如何通过硬件外设控制器来完成信息交互呢,就是对这些外设提供的相关寄存器进行读写,也就是我们说的硬件编程。
【zynq课程笔记】【裸机】【第7课 】【硬件编程原理】
了解了所谓的硬件编程的定义和意义之后,相信大家也都了解了硬件编程的重要性,掌握硬件编程属于开发Zynq SoC FPGA的必备技能。不同的硬件外设,功能作用不同,其寄存器设定和对应的物理意义也都不一样,很难找到一个通用的程序或者方法,来对任意一个外设进行编程。所以只能具体硬件具体分析,具体功能具体编程实现。
GPIO,属于是Zynq SoC中最简单的外设控制器,也是所有基于CPU结构的芯片中都会有的一类基础外设,因此,我们本节课程,就使用GPIO这个基础且简单的外设控制器来带领大家入手Zynq SoC FPGA的PS硬件编程。
3、典型GPIO结构
要了解GPIO的结构,我们先来说说我们一般希望使用GPIO做什么。还是前面说的:
我们可能会使用GPIO来驱动一个继电器,或者LED灯亮灭。这类应用,只需要GPIO能够输出高电平或者低电平就可以了。
或者使用GPIO来检测一个按键是否按下,这类应用,只需要GPIO能够检测管脚的高低电平即可。也就是说:
l GPIO需要有一个寄存器来存储CPU告知它的希望其输出的电平状态(高/低)
l GPIO需要有一个寄存器能够存储外部管脚当前的电平状态(高/低)
同时,作为GPIO,还需要设置其方向,因为一个GPIO在不同的场景下需要工作在不同的方向,检测按键状态,则需要其工作在输入检测模式,而驱动继电器或者LED,则需要其工作在输出模式。处于输入状态的时候无法做输出,因此:
l GPIO需要有一个寄存器来存储并控制管脚的方向
典型的就是下面这张图。
【zynq课程笔记】【裸机】【第7课 】【硬件编程原理】
R1:方向/输出使能控制寄存器
R2:输出状态/数据寄存器
R3:输入状态/数据寄存器
用更加形象的图示描述,就是下面在Quartus软件中绘制的逻辑图。
【zynq课程笔记】【裸机】【第7课 】【硬件编程原理】
上面这个图所描述的GPIO,严格意义上来说,只是CPU GPIO的成员之一,我们常称为1位,在CPU系统中,由多位这样的成员组合在一起,构成的才是一个完整的GPIO。例如对于大家最熟悉的STC89C52单片机,其总共有4个GPIO(P0、P1、P2、P3),每个GPIO包含8位,每个GPIO由一组寄存器共同管理其功能。寄存器的每一位对应GPIO中对应引脚位,例如下图中,如此,有3个8位的寄存器,分别用来对应存储这个8位GPIO的输出状态(OE[7:0])、输出值(WDATA[7:0])、输入值(RDATA[7:0]),则CPU编程时,只需要读写这三个寄存器的值,就能设置和查询GPIO的输出状态,输出值、输入值了。
【zynq课程笔记】【裸机】【第7课 】【硬件编程原理】
4、增强的GPIO结构
除了这些最最基础的功能,根据不同的应用场景或者需求,各芯片厂家一般都会在基本功能的基础上,加上一些其他功能,比如置位/清零寄存器、中断屏蔽寄存器
置位和清零寄存器
对于一个8位的GPIO,当需要仅对其中1位的输出值进行更改,而不影响其他位的功能时,则需要先读取这个8位寄存器WDATA的值,然后将这个值的对应位修改为希望设置的状态,然后再写入WDATA寄存器。
例如设置bit2位为1,如下所示:
uint8_t value;
value = (uint8_t *)WDATA;
value = value | (1<<2);
(uint8_t *)WDATA = value;
再如设置bit3位为0,如下所示:
uint8_t value;
value = (uint8_t *)WDATA;
value = value & (~(1<<3));
(uint8_t *)WDATA = value;
这样的操作方式,从表层看,需要3条语句才能实现一次操作,增加了编程的复杂性。而实际上,最终编译得到的指令更多。执行效率也有所下降。
为了解决这个问题,现在的MCU基本都配备了专门用来设置GPIO_SET和清零GPIO_CLR某特定bit的寄存器,只需要往该寄存器的对应位写入1,就能实现对该GPIO输出值的设置(输出1)或清零(输出0)操作,写入0的位输出则不受影响。这样,通过增加GPIO硬件的复杂度,来降低CPU编程的复杂性,提升GPIO的控制效率。
中断屏蔽寄存器
在一些对外部信号变化要求及时响应的应用中,需要CPU能够及时知道外部信号已经发生了变化,以便于迅速做出反应。MCU采用中断的方式来应对这样的场景。而对于GPIO来说,是MCU与外界信号交换的最直接的接口。所以我们希望当GPIO检测到对应信号变化之后能够及时的通知CPU去处理,这也意味着要求GPIO有发出中断请求的能力。
由于在实际应用中,仅有GPIO中的部分bit需要发出中断请求,其他bit不需要或者说不得发出中断请求,因此需要有一个开关,来分别控制GPIO的每个位是否被允许发出中断请求,这个寄存器一般被称为中断屏蔽寄存器(INTMASK),如果该寄存器中对应位为1,则对应的GPIO的bit被允许发起中断请求,为0则不允许发起中断请求。
当然,不同的处理器系统,所使用的GPIO的功能和复杂度也不一样。这里只是简单的为大家介绍了最常见的一些功能及对应的寄存器。当我们编程时,需要查阅该器件datasheet中相应的信息,已获得这些寄存器的分布、功能以及使用方法。
Zynq7000 GPIO结构
UG585的第390页贴出了Xilinx Zynq 7000 SoC FPGA的GPIO的功能框图。
【zynq课程笔记】【裸机】【第7课 】【硬件编程原理】
5、GPIO控制的编程思路
初始化
根据GPIO对应位的工作场景,设置其方向、中断屏蔽位、中断检测类型。
工作
输入:若开启了中断,则编写中断处理函数,若不开启中断,则在需要的时候直接读取输入寄存器的值;
输出,则通过写数据寄存器或输出置位/清零寄存器来修改该位的输出值。
6、GPIO控制的编程方法
单输出型应用场景
使用GPIO控制LED
在ACZ702开发板上,设计了一个LED灯,连接到了Zynq芯片的MIO7脚上。MIO7输出高电平,则点亮LED灯,MIO7输出低电平,则LED熄灭。
【zynq课程笔记】【裸机】【第7课 】【硬件编程原理】
场景分析:该场景下只需要简单的控制GPIO的对应位输出高低电平即可,用不到中断功能。
初始化
关闭中断
INT_DIS/ INT_EN:
这是一组作用于同一个功能的2个独立的寄存器,一个负责使能GPIO的每一位的中断,另一个负责禁止GPIO的每一位的中断。
本应用中,对应GPIO无需开启中断,所以设计时针对INT_DIS/ INT_EN寄存器,需要明确:
关闭某位中断该操作哪个寄存器,往该寄存器写0关闭中断,还是写1关闭中断。
根据UG585中的描述,对INT_DIS寄存器的对应位写1就可以禁止该位对应的IO产生中断。所以本例中初始化时,理论上需要对INT_DIS寄存器进行操作。设置MIO7对应的中断控制位不打开。
Data = (1<<7);
Xil_Out32(XPAR_PS7_GPIO_0_BASEADDR + XGPIOPS_INTDIS_OFFSET, Data);
INT_MASK:用来指示GPIO中哪些位使能了中断,某位为1则代表该位对应的GPIO中断被屏蔽了,无法产生中断。此为指示型的只读寄存器,因此无需设置和修改,只在需要确认某个GPIO的中断是否已经使能的时候读取。
因为不使能中断,因此与中断相关的各个模式设置寄存器以及状态寄存器都不需要在初始化中再处理。因此更多相关寄存器本节暂不介绍,再后续讲解中断课程时再来详细介绍。
设置方向和输出使能
OUTEN、DIRM寄存器
由于是点亮LED,属于输出型GPIO,因此,根据GPIO的结构图知道,需要使output enable信号为高电平,以使能IO Pin上的三态缓冲器输出。而output enable为1的条件则是OUTEN和DIRM两个寄存器对应的位都为1,。
所以需要在初始化时,向这两个寄存器中对应位均写入1。由于这两个寄存器均是32位同时写入型,所以为了不干扰寄存器中其他位的值,需要采用read-modify-write的操作顺序,也就是先读出,再修改,最后再写回。
//设置IO方向,bit7的方向为输出
reg_val = Xil_In32(XPAR_PS7_GPIO_0_BASEADDR + XGPIOPS_DIRM_OFFSET);
Data = reg_val | (1<<7);
Xil_Out32(XPAR_PS7_GPIO_0_BASEADDR + XGPIOPS_DIRM_OFFSET, Data);
//设置输出使能,bit7输出使能
reg_val = Xil_In32(XPAR_PS7_GPIO_0_BASEADDR + XGPIOPS_OUTEN_OFFSET);
Data = reg_val | (1<<7);
Xil_Out32(XPAR_PS7_GPIO_0_BASEADDR + XGPIOPS_OUTEN_OFFSET, Data);
虽然这种操作相对繁琐,但是这些操作在整个程序运行周期内,往往只需要执行一次,所以稍微繁琐一点也关系不大。
设置GPIO的状态
操作DATA寄存器
写DATA寄存器时,是32位同时写入的,所以,为了不干扰其他位的值,需要先将该寄存器的值读回来,修改其中想要修改的位后,再写回该寄存器,也就是上面提到的read-modify-write操作顺序,这种操作相对繁琐。
操作MASK_DATA寄存器
由于设置GPIO输出状态的操作在整个程序的运行周期内可能需要执行很多次,直接使用写DATA寄存器的方法一定程度上会影响IO翻转效率。所以Zynq的GPIO提供了单独操作指定位的快速操作方法。也就是MASK_DATA寄存器。
写MASK_DATA寄存器时需要注意,对于每一个GPIO Bank,由MASK_DATA_LSW和MASK_DATA_MSW两个寄存器组成,其中MASK_DATA_LSW控制该组GPIO中低16位的状态,MASK_DATA_MSW控制该组GPIO中高16位的状态。
而每个寄存器又分为高16位和低16位2部分,两部分的功能不同。高16位对应描述当前的写入操作对哪些位生效,低16位对应要写入的值。
GPIO[31:0] MASK_DATA_MSW
控制GPIO[31:16] MASK_DATA_MSW[31:16] 控制对应位值是否更新
MASK_DATA_MSW[15:0] 指定对应位要更新的值
MASK_DATA_LSW
控制GPIO[15:0] MASK_DATA_LSW[31:16] 控制对应位值是否更新
MASK_DATA_LSW[15:0] 指定对应位要更新的值
MASK_DATA的高16位中,哪些位为0,这些位对应的数据寄存器的值才允许被更新,更新的值由MASK_DATA寄存器的低16位来指定。
//设置bit7输出1
Data = ((~(1<<7)) << 16) | (1<<7);
Xil_Out32(XPAR_PS7_GPIO_0_BASEADDR + XGPIOPS_DATA_LSW_OFFSET, Data);
//设置bit7输出0
Data = ((~(1<<7)) << 16) & (~(1<<7));
Xil_Out32(XPAR_PS7_GPIO_0_BASEADDR + XGPIOPS_DATA_LSW_OFFSET, Data);
7、思考和练习
在上述内容中,我们讲解了使用Zynq的GPIO驱动一个LED亮灭的编程思路和方法,该场景下,是使用GPIO作为单独输出功能使用的。除了输出,GPIO还有一个很典型的应用场景是输入,比如连接按键开关。
在ACZ702开发板上,设置了3个按键,其中S3是复位按键,不作为用户功能按键使用,我们无法对其编程操作,S1则接到了PS的MIO47管脚上。
【zynq课程笔记】【裸机】【第7课 】【硬件编程原理】
所以,请大家根据本节课讲解的思路和方法,实现以下功能。
读取S1按键的电平,
当S1按键为按下状态时,驱动PS_LED以1S的频率闪烁(注意理解1S的频率闪烁和1S的时间翻转两种描述之间的差别),
当S1释放后,停止闪烁,
确保S1释放后PS_LED处于熄灭状态。
通俗点讲,就是S1被按下后PS_LED就以1S的频率闪烁,释放后就熄灭且不再闪烁,