PWM驱动的编写心路

记录一个PWM驱动的编写过程

笔者从零入门编写一个PWM驱动的心路历程。
本科阶段的知识大多停留在知其然但不知其所以然的层次,大三做智能车时使用的K60芯片,通过PWM模块的输出模式控制电机输出和输入捕捉模式获取编码器输入,但是驱动层商家已经把函数封装好了,应用层只需要简单的调用就可以。

//pwm模块初始化
ftm_pwm_init(ftm0,ftm_ch0,15000,0);
ftm_pwm_init(ftm0,ftm_ch1,15000,0);
ftm_pwm_init(ftm0,ftm_ch2,15000,0);
ftm_pwm_init(ftm0,ftm_ch3,15000,0);
//pwm模块电机参数配置
ftm_pwm_duty(ftm0,ftm_ch0,0);
ftm_pwm_duty(ftm0,ftm_ch1,duty);
//pwm模块编码器速度获取
ftm_quad_get(ftm2);

这是我们当初唯一使用到的PWM的函数。走嵌入式方向有一个坑,大部分时间都在改别人的代码,真证自己写一个完整的代码的机会很少,很多时候,我们对嵌入式的了解就只局限在应用层,更深层次的驱动层和系统层很难有机会去真正的掌握。
或许是缘分,我实习工作的第一个任务就是编写一个PWM驱动出来。
废话少说,接下来开始步入正题。
我得到的资料有一个英文的芯片手册、一个SDK模板和一个在网上找不到的类似keil的编译器,再加上一台电脑,开始了我的驱动之旅。
了解正点原子的人知道,他给的官方例程有两个版本,对于入门来说,大部分人都是使用的库函数版本,我也不例外,虽然对函数调用和这种分文件存储的方式非常熟悉,但是寄存器版本的熟悉是我有意避开的,寄存器操作与配置于我来说就是从零开始。
公司给我的SDK里并没有PWM模块,一个空的PWM.c和一个PWM.h文件。
第一次使用英文手册,需要完成文档的翻译,要不然是真的看不懂。我逐行逐行的敲着去把文档翻译成中文,这里翻译软件建议使用谷歌翻译,还是很标准的。
起初,我打算从头开始,把芯片前面的概述都搞清楚之后,再去理解PWM驱动的模块。浪费了一段时间后,发现这样并不合算,按照以往的经验,芯片手册前面的部分只需要了解模块相应的引脚就行。查阅芯片手册后,我找到了该芯片的12个可以配置成pwm模块的引脚。没错,这个芯片有6个 pwm group,共12个channel,我将这些引脚放在了一个数组里,一开始的打算是写个循环将这些引脚全部初始化成PWM引脚(错误做法,在后面会提及)。

 static const uint8_t pwm_pin_mux[12]=
{
 GPIO0,
 GPIO1,
 GPIO2,
 GPIO3,
 GPIO4,
 GPIO5,
 GPIO6,
 GPIO7,
 GPIO8,
 GPIO9,
 GPIO17,
 GPIO18, 
};

花了大概两天时间,我翻译完了PWM模块的英文文档,但仍然感觉一筹莫展,无从下手。
以往的经验告诉我,应该找一下别的驱动底层的实现逻辑。我随便找了一个ADC驱动,代码编写非常规范。我本着应该从下到上的原则,又开始翻译ADC模块的手册。其间,我问了一个同事这些流程,他和我说IIC驱动是他写的,我就又去熟悉IIC驱动,后来总监看我进度太慢,过来问我,让我不要看IIC,比较难,说SPI比较简单,可以先熟悉SPI驱动,我又开始看SPI驱动。每次,我都是从下到上,先翻译英文手册,然后熟悉代码,整理流程,到最后除了每个模块的中断我依然不知道指向哪儿,大概流程有了一个掌握。
虽然大概流程清楚了,但其实每个模块的流程并不是完全相同的,我仍然感觉难以开始。于是,这个时候,我才发现原来PWM.h文件里原来是有内容的,定义了一些结构体变量并且声明了四个函数,给我指明了我大概的PWM驱动的应该有的一个框架。
于是,依据别的驱动的流程配置方法和头文件里面的内容,我大概的整理出了一些模式配置的函数。这个时候的代码长这个样子:

static void pwm_ch_counter_mode_config(pwm_reg_t *pwm_reg_addr,PWM_CH_COUNTER_MODE_E pwm_ch_counter_mode_conf)
{
 if(pwm_ch_counter_mode_conf==PWM_COUNT_UP)
 {
 }
 else if (pwm_ch_counter_mode_conf==PWM_COUNT_UP_DOWN)
 {
 }
 return 0;
}

这是其中的一个模式配置的框架,可以看出这里并没有对寄存器进行配置,将头文件里定义的结构体里包含的变量都通过这种方式在.c文件里定义成了相应的函数。
在写这儿时,由于一个判断太多,我又是按照以往的经验我选择使用switch语句来进行更加直观的判断,代码是这个样子的:

 switch(pwm_clk_divide_conf)
 {
  case PWM_CLK_DIVIDE_NONE:
  {
  
  }break;
  case PWM_CLK_DIVIDE_2:
  {

  }break;
  case PWM_CLK_DIVIDE_4:
  {

  }break;
  case PWM_CLK_DIVIDE_8:
  {

  }break;
  case PWM_CLK_DIVIDE_16:
  {

  }break;
  case PWM_CLK_DIVIDE_32:
  {

  }break;
  case PWM_CLK_DIVIDE_64:
  {

  }break;
  case PWM_CLK_DIVIDE_128:
  {

  }break;
  
  default:
  break;
 }

这儿走了一个新人最容易犯的错误,对芯片驱动来说,switch语句对性能的影响太大了。这儿有一种更好的方法,用空间换时间,用数组随机访问时间复杂度为O(1)的特性,根据下标直接访问该地址的数据。
下一步,我开始依照手册上的寄存器说明进行相应模式的寄存器配置,我去理解了别的模块的位操作方法,把一些简单显而易见的寄存器的位配置了出来。这个时候代码是这个样子的:

static void pwm_ch_counter_mode_config(pwm_reg_t *pwm_reg_addr,PWM_CH_COUNTER_MODE_E pwm_ch_counter_mode_conf)
{
 pwm_reg_addr->PWMCTL &= ~DRV_PWM_COUNTER_MODE; // 1
 if(pwm_ch_counter_mode_conf==PWM_COUNT_UP)
 {
  pwm_reg_addr->PWMCTL &= ~DRV_PWM_COUNTER_MODE;//0-count_up   //2
 }
 else if (pwm_ch_counter_mode_conf==PWM_COUNT_UP_DOWN)
 {
  pwm_reg_addr->PWMCTL |= DRV_PWM_COUNTER_MODE;//1-count_up_down  //3
 }
 return 0;
}

其中涉及一些简单的位操作,DRV_PWM_COUNTER_MODE是一个宏定义的常量。
**在所有的驱动寄存器进行位配置时,有一个核心观念,不能改变除特定位其余所有位的值。**图中1是代表通过与反一个16进制常量对PWMCTL寄存器进行指定位的清零操作。2是根据芯片手册上该模式对应的位的值进行的配置,可以看出,当该位为0代表count_up模式,该位为1代表count_up_down模式。
我对头文件里的所有模式进行一一的位配置,只用了很短的时间,就将绝大部分都配置完了。但是,完了之后,我又陷入了迷茫。事实证明,写代码根本不费时间,真正花时间的是找到自己要写啥。
在配置这些寄存器的位时,我发现不同channel的同一模式的配置竟然是由不同的位代表的,这给我带来了很大麻烦。因为之前的函数形参传入的只有一个地址和选择的模式。地址代表入口,模式用来判断,如果每个通道是由不同的位来表示的,那岂不意味着我要为每个通道都写一个函数嘛,这太可怕了。因为我了解的其余驱动都是传入的这俩个形参,于是我也深信不疑,后来发现实在走不通。其实因为我看的驱动并没有出现过这种多通道的模式选择由不同的位来判断。于是,我在每个模式配置函数加入了通道这个形参,开头加了一个语句来执行相应的参数选择,这个时候,代码是这样的:

static void pwm_ch_counter_mode_config(uint8_t ch,pwm_reg_t *pwm_reg_addr,PWM_CH_COUNTER_MODE_E pwm_ch_counter_mode_conf)
{
 int DRV_PWM_COUNTER_MODE=ch_array[ch][0];
 pwm_reg_addr->PWMCTL &= ~DRV_PWM_COUNTER_MODE;
 if(pwm_ch_counter_mode_conf==PWM_COUNT_UP)
 {
  pwm_reg_addr->PWMCTL &= ~DRV_PWM_COUNTER_MODE;//0-count_up
 }
 else if (pwm_ch_counter_mode_conf==PWM_COUNT_UP_DOWN)
 {
  pwm_reg_addr->PWMCTL |= DRV_PWM_COUNTER_MODE;//1-count_up_down
 }
 return 0;

可以看到函数体第一行,执行了相应通道的参数选择。由于有多种模式需要配置,所以我这里定义的是一个二维数组,共有12列,每一列代表一个通道的一个模式的配置参数。二维数组长这个样子(里面的参数都是头文件里的宏定义,宏定义应该大写,此处勿纠结):

int8_t ch_array[12][6]=
{
 {pwm_ch0_counter_mode,pwm_ch0_group_mode,pwm_ch0_pwmen,pwm_ch0_pwminv,pwm_ch0_capctl,pwm_ch0_trig_type},
 {pwm_ch1_counter_mode,pwm_ch1_group_mode,pwm_ch1_pwmen,pwm_ch1_pwminv,pwm_ch1_capctl,pwm_ch1_trig_type},
 {pwm_ch2_counter_mode,pwm_ch2_group_mode,pwm_ch2_pwmen,pwm_ch2_pwminv,pwm_ch2_capctl,pwm_ch2_trig_type},
 {pwm_ch3_counter_mode,pwm_ch3_group_mode,pwm_ch3_pwmen,pwm_ch3_pwminv,pwm_ch3_capctl,pwm_ch3_trig_type},
 {pwm_ch4_counter_mode,pwm_ch4_group_mode,pwm_ch4_pwmen,pwm_ch4_pwminv,pwm_ch4_capctl,pwm_ch4_trig_type},
 {pwm_ch5_counter_mode,pwm_ch5_group_mode,pwm_ch5_pwmen,pwm_ch5_pwminv,pwm_ch5_capctl,pwm_ch5_trig_type},
 {pwm_ch6_counter_mode,pwm_ch6_group_mode,pwm_ch6_pwmen,pwm_ch6_pwminv,pwm_ch6_capctl,pwm_ch6_trig_type},
 {pwm_ch7_counter_mode,pwm_ch7_group_mode,pwm_ch7_pwmen,pwm_ch7_pwminv,pwm_ch7_capctl,pwm_ch7_trig_type},
 {pwm_ch8_counter_mode,pwm_ch8_group_mode,pwm_ch8_pwmen,pwm_ch8_pwminv,pwm_ch8_capctl,pwm_ch8_trig_type},
 {pwm_ch9_counter_mode,pwm_ch9_group_mode,pwm_ch9_pwmen,pwm_ch9_pwminv,pwm_ch9_capctl,pwm_ch9_trig_type},
 {pwm_ch10_counter_mode,pwm_ch10_group_mode,pwm_ch10_pwmen,pwm_ch10_pwminv,pwm_ch10_capctl,pwm_ch10_trig_type},
 {pwm_ch11_counter_mode,pwm_ch11_group_mode,pwm_ch11_pwmen,pwm_ch11_pwminv,pwm_ch11_capctl,pwm_ch11_trig_type}
};

写到这儿,其实只完成了寄存器的简单配置,一些中断位的配置和整体的工作流程我发现我依旧很迷茫。我重新开始整理逻辑流程,我重新梳理了手册中pwm模块的每一行每一幅图像,手册解释的太少了,我发现我根本理解不了。我重新整理了pwm模块的每一个寄存器的每一位代表的意义,我发现中断位要干嘛什么时候配置我还是理解不了。
写到这儿,唯一的感觉就是写他妈呢,写不出来,开除算了。于是,本文完。
开个玩笑,自暴自弃了两天,重新捡起来,上网查资料,发现网上总共有两类可供参考的资料,一类是Linux下的PWM驱动编写,一类是STM32系列芯片的PWM驱动编写。看了好多博客,总算对PWM驱动原理和流程有了一个大概的理解。
网上主流的PWM输出驱动配置大概的一个流程是这样的:

  1. 使能相关时钟
  2. 初始化IO口为复用功能输出
  3. 初始化定时器
  4. 初始化输出比较参数
  5. 使能预装载寄存器
  6. 使能定时器
  7. 通过改变比较值改变占空比。

但是呢,这个是STM32芯片的驱动开发,和我使用的芯片还是有点差距的,我详细整理过所有的寄存器配置,我发现我所使用的芯片好多的位仍不知道该什么时候使用。
相比怎样配置,寄存器的位何时配置才是我写驱动时碰到的最难,又最耗时间的问题,会让人一筹莫展。
暂时先丢掉这些疑问,我先根据逻辑流程进行简单PWM波输出的实验。

  1. 时钟初始化,对APB总线上的时钟进行分频初始化,得到PWM模块时钟。
  2. 选择计数模式,这里我选择是count_up模式,通过调用模式函数配置该模式。
  3. 设置PWM周期,将频率传入,求倒数后得到PWM周期,将周期乘以PWM时钟源的值放在value_load里,通过位操作写入PWM01LOAD的低16位。
  4. 设置PWM0引脚的脉冲宽度,通过位操作写入PWM0CMP[15:0]。
  5. 设置PWM1引脚的脉冲宽度,通过位操作写入PWM0CMP[31:16]。
  6. 死区插入,将0x0写入PWM01DB。
  7. 使能输出PWM信号,通过调用函数使能某channel。

在寄存器配置时,容易产生一个小的误区,你要理解计算机,位并不是配置好了就可以使用,你要用你的代码清清楚楚的告诉它,什么时候该做什么事。
我在配置外设时钟时,第一个难题就是不确定PWM模块的时钟源连接的是高速APB0总线时钟还是低速APB1总线时钟。我参考了其余驱动模块的例程,我发现基本是没有涉及到模块时钟的初始化,只有一个总的flash时钟模块的初始化,这与我的认知不相符合,在我看来,为了节省能耗,所有的外设都有自己的时钟模块,在使用时开启,这给我造成了很大困难。
PWM输出波的原理其实很简单,通过load值确定自己的频率,通过调节通道的capture值调节高低电平的占空比,这样输出后就会得到一个稳定的PWM波形。
为了测试,我在头文件里进行了变量的默认定义

#define NANO_PWM_DEFAULT_CONFIG           \
{                                         \	
	.counter_mode = PWM_COUNT_UP,         \
	.ch_a         = PWM_CH_ENABLE,        \
	.ch_b         = PWM_CH_ENABLE,        \
	.ch_a_pin     = GPIO0,                \
	.ch_b_pin     = GPIO1,                \
	.load_value   = 0x320,                \
	.compare_a    = 0xc8,                 \
	.compare_b    = 0x258,                \
}

按照头文件的指示,我将逻辑流程分成了两个部分。

  1. PWM时钟配置及初始化
int32_t drv_pwm_init(PWM_CLK_DIVIDE_E pwm_clk_divide);
  1. PWM配置及使能输出
int32_t drv_pwm_ch_pwm_enable(uint8_t ch,pwm_group_pwm_config_t config);

第一部分主要是PWM时钟源的配置,主要代码流程是

 pwm_clk_divide_config(pwm_base_addr,pwm_clk_divide);`

第二部分是PWM输出模式的配置,第二部分主要代码流程是

 pwm_reg_t *pwm_base_addr=(pwm_reg_t*)soc_perihperal.pwm.reg_base;
 pwm_gpio_init(config.ch_a_pin);
 pwm_gpio_init(config.ch_b_pin);
 pwm_ch_counter_mode_config(ch,pwm_base_addr,config.counter_mode);
 pwm_ch_mode_config(ch,pwm_base_addr,config.ch_a);
 pwm_ch_mode_config(ch,pwm_base_addr,config.ch_b);

于是乎,又出现了新的问题。我发现我没办法获取计数器当前的值,没办法获取那我该如何和campture比较来输出电平呢,而且,电平的输出是要我配置io口来输出嘛,总觉得不太可能。
我想到了,我应该去博客下看一下STM32定时器产生PWM波是如何比较的。
博客找了一堆,发现这个值应该是存储在寄存器的某些位里,仔细查询芯片手册后,发现PWMnmCOUNT寄存器包含当前计数器的值。我于是又产生了一个疑问,寄存器位在我理解应该是存储二进制位,我们要做的是对特定位进行读取然后运算然后写。
在这儿,我们会发现寄存器的位并不是所有的位都是需要我们自己去规定运算方法的,有些位被硬件电路所控制,是不需要算法进行操作的。
在这儿,我又发现两个问题,我该怎样设置预分频系数呢,手册中并没有明确说明PWM模块时钟源应该为多少Hz。另一个问题是对于整体流程的模糊,系统启动时以固定的系统时钟对flash进行初始化,但是总线时钟和系统时钟并不是一个东西,那么我是不是应该以总线时钟对整个flash进行初始化呢?但这样合理吗?如果我以系统时钟对flash进行的初始化,那么总线时钟会如何设置呢?
在观看了系统时钟模块的整体流程后,发现时钟之间是有一个固定关系的,初始化系统时钟参数后,总线时钟参数就可以计算出来。关系如下:

    uint32_t sys_clk;    = target_sys_clk
    uint32_t main_clk;   = target_sys_clk / mclk_ratio
    uint32_t apb0_clk;   = target_sys_clk / mclk_ratio / apb0_ratio
    uint32_t apb1_clk;   = target_sys_clk / mclk_ratio / apb1_ratio

这儿我又产生了一个疑问,

    drv_flash_init(app_get_sys_clk());
    __set_VBR((uint32_t) & (__Vectors));    
    csi_cache_enable_profile();
    csi_cache_set_range(0,0x18000000,CACHE_CRCR_512K,1);
    csi_icache_enable ();
    app_pmu_power_on_init();

可以看到app_get_sys_clk()是作为回调参数直接在开头使用的,可是代码整体结构来说尚未进行app_pmu_power_on_init(),也就是说板子没有进行时钟初始化就已经在使用时钟模块初始化flash了,不太理解。
现在已经找到时钟的获取方法了,我在PWM模块中使用APB0总线:app_get_apb0_clk()
在配置PWM时钟源时主要需要完成的任务是:

  1. 获取总线时钟源存取在变量中
  2. 用该变量除以传入的预分频参数得到时钟源

这儿又产生啥疑问了呢,我明明可以根据传入的参数直接获取到时钟源,我为啥还要进行寄存器的配置呢?
仔细想想后,软件和硬件思维确实不太一样,虽然我将软件配置成了这样,但是对硬件来说只有某些位变成芯片手册上所要求的参数后,才具有实际意义。嵌入式的根本意义就在于让硬件理解软件,在硬件基础上进行软件的配置。
想到这儿后,疑问越来越多,如果把开发板理解成一个cpu的话,当我在配置寄存器的时候我到底在干嘛呢?一个成熟的操作系统可以屏蔽系统底层硬件的不同,使得软件程序直接在特定环境中运行,底层的操作系统完成的是对硬件的调用,可以理解成将所有的硬件的驱动集成在一个程序里,然后加上特有的任务管理,进程管理等就成为一个小型的操作系统。我们平时开发所说的嵌入式应用程序也就成为了我们平时所说的应用软件。
大概流程是这样的,我查阅资料后,发现有些细节并不是很对,开发板芯片其实并不是一个简单的CPU。
这儿必须要搞清楚,CPU芯片和SoC芯片的概念。

SoC芯片:CPU+外设控制电路,称为系统级芯片,也称片上系统。
CPU芯片:运算器+控制器,现在基本没有纯粹的CPU芯片,都是SoC。

这样一来,我们就很清晰了。上面产生的混乱完全是因为不懂这些基础概念,CPU通过总线将各种外部设备连接起来构成SoC,这里的外部设备就指UART、IIC、PWM等外围控制电路,我们所说的编写驱动实质上就是编写CPU操作各种外设控制器的程序。
这样一来,思路就比较清晰了。我们将程序下载在flash里,CPU负责去运行这个程序,程序里面读写的寄存器则是外围控制电路的寄存器,并不是CPU里面的寄存器。
我终于搞清楚了,为什么刚来公司时,和我说这个芯片是我们公司的。
离职在即,无心学习。

  • 13
    点赞
  • 36
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值