单片机程序架构 --- 三层架构

2020-03-08 19:23发布

本帖最后由 会笑的星星 于 2020-1-9 11:17 编辑

之前在“单片机程序---二层架构”里谈过设计二层架构的方法,也强调了二层架构的优点在于分离硬件层,使得硬件层更具有通用性。但缺点在于应用层的程序过于耦合,导致应用层的程序比较复杂并且复用性不够。为了解决二层架构的缺点,还需要把应用层一分为二,也就是中间层以及应用层,这两层与硬件层一起构成了所谓三层架构,如下图示。
clipboard126.png
这里多出来的中间层就是对应用层功能的抽象。抽象的好处在于一个是简化了应用层的设计,另一个好处就是就是中间层的代码复用性提高。这里我将以之前在“串口模块”这篇文章中谈到的串口功能为例,看看如何设计单片机程序的三层架构。关于如何抽象,建议看看我之前写的“程序的抽象”那篇文章。这里我以之前讲过的串口模块为例,来说说设计三层架构的方法。我先描述一下问题。
(1)应用层利用串口发出调试信息,这些调试信息包括 : 字符串、十六进制的字符形式。(2)应用层处理串口接收到的信息以便完成一些事情。
在解决上述两个问题之前,我再次强调一下程序分层的一个重要原则 --- 尽量只有上层调用下层,而不是相反。如果要从下层调用上层,原则上采用回调函数的方式来实现,但在实际中需要根据情况来决定。
先看问题(1),解决串口发出调试信息的问题。我们按照三层架构的方法来设计这个功能。首先我们看应用层。
按要求,需要向应用层需要提供两个抽象接口。一个用于发送任意字符串。另一个用于发送任意长度的十六进制对应的字符数据。如下的例子所示。
  1. //应用层
  2. #include "mid_serial.h"

  3. test_dat[6] = {0x01,0x02,0x03,0x04,0x05,0x06}

  4. //函数功能: 发送字符串
  5. serial_u0_send_str("tx:");
  6. //函数功能: 将十六进制转换为对应的字符并发送出去
  7. serial_u0_send_hex_char(test_dat,5);

  8. 结果:
  9. tx:01 02 03 04 05
复制代码serial_u0_send_str()以及serial_u0_send_hex_char()函数在“串口模块“那篇文章讲过,这里不再详细说明。
这两个抽象接口的具体实现单独的封装在一个名为mid_serial.c的文件中,接口声明在mid_serial.h中,这两个文件构成我们所谓的中间层。为了能让中间层发送相关的功能工作,这个mid_serial.c文件内还需要做两件事。一个是定义中间层需要的变量,比如串口发送缓存区等。另一个是管理串口发送缓冲区的函数,这个函数属于中间层自己的,但是需要应用层调用才能让中间层工作。我把这几个函数的声明写在下面,具体源码见“串口模块”那篇文章。
  1. //中间层 --- mid_serial.h

  2. #define UART_TX_BUF_LENGTH_32  31
  3. #define UART_TX_BUF_LENGTH_64  63
  4. #define UART_TX_BUF_LENGTH_128 127

  5. #define UART0_TX_BUF_COUNT    UART_TX_BUF_LENGTH_64
  6. //设置发送缓冲区长度,这里的长度是64个字节
  7. #define UART0_TX_FIFO_LENGTH  (UART_TX_BUF_LENGTH_64+1)

  8. //初始化串口模块相关参数
  9. extern void serial_parameters_init(void );
  10. //串口发送缓冲区的管理函数,用于执行具体的数据发送。这个函数需要在应用层定时调用,
  11. //定时间隔由波特率决定
  12. extern void serial_u0_send_manage();
  13. //发送字符串
  14. extern void serial_u0_send_str(unsigned char *ptxs);
  15. //发送十六进制对应的字符格式数据
  16. extern void serial_u0_send_hex_char(unsigned char *ptxd,unsigned char len);
复制代码这样,中间层关于发送部分的代码就完成了。你可以发现,这样做一个好处是可以让这个模块更为通用,能方便的应用在其他项目上。另一个好处是让整个程序的结构更为清晰。
到目前为止,我们完成了问题(1)的应用层以及中间层的设计。最后我们还需要设计的是中间层需要的硬件层功能。
硬件层一个是需要提供串口的初始化函数确保硬件串口能工作。另一个是需要为中间层提供数据写入接口,以便把数据写到硬件寄存器,如下图所示。
  1. //硬件层 --- hal.h

  2. extern void hal_uart_init(void );
  3. //这个函数被中间层serial_u0_send_manage()直接调用
  4. extern void hal_uart_set_tx_data(unsigned char tx_data);
复制代码上述两个函数在讲“单片机程序设计---二层架构”中提到过,这里也不再说明。

至此,问题(1)的三层架构就设计完成了。为了更好的理解,我把他们之间的调用关系写在下面。
  1. //应用层
  2. #include "hal.h"
  3. #include "mid_serial.h"

  4. unsigned char clk_2ms;

  5. void app_clk_2ms(void )
  6. {
  7.   serial_u0_send_manage();  
  8. }

  9. main()
  10. {
  11.   hal_uart_init();  
  12.   serial_parameters_init();
  13.   //发送字符串
  14.   serial_u0_send_str("tx:");
  15.   while(1)
  16.   {
  17.      if(clk_2ms)
  18.      {
  19.        clk_2ms = 0;
  20.        app_clk_2ms();
  21.      }  
  22.   }
  23. }

  24. //中间层 --- mid_serial.h
  25. //以下函数的实现在mid_serial.c中
  26. extern void serial_parameters_init(void );
  27. extern void serial_u0_send_manage(void );
  28. extern void serial_u0_send_str(unsigned char *ptxs);
  29. extern void serial_u0_send_hex_char(unsigned char *ptxd,unsigned char len);

  30. //硬件层 --- hal.h
  31. extern void hal_uart_init();
  32. extern void hal_uart_set_tx_data(unsigned char tx_data);
复制代码你可以看到,从应用层到硬件层都是上层调用下层,没有出现下层调用上层的情况。你可能会问,如果出现了下层调用上层的情况怎么办,这就是问题(2)中要解决的问题。
根据问题(2),应用层需要处理串口接收到的数据,数据流要从硬件寄存器 -> 中间层 -> 应用层。这个过程与串口数据发送相反。因此,这里会涉及硬件层调用中间层的函数,中间层调用应用层的函数。虽然前面我说过,下层调用上层,一般采用回调函数的方式实现。但是我在单片机程序设计---二层架构中说过,从程序实现的复杂度以及软件的通用性的角度来说并不建议使用回调函数来实现下层调用上层(如果没有看那篇文章的话建议先看看),而是在下层直接调用上层函数。
我们还是从应用层开始来解决问题(2)。
应用层需要处理从中间层过来的数据。一般而言,中间层需要把数据缓存区指针以及数据长度给到应用层。这里就会涉及中间层(下层)调用应用层(上层)的问题。我们先编写应用层函数,用于处理来自中间层的数据。如下代码。
  1. //应用层代码

  2. //函数功能:应用层处理中间层过来的数据
  3. void app_u0_rx_handle(unsigned char *p, unsigned char len)
  4. {
  5. }
复制代码接下来我们编写中间层的代码,用于把硬件层接收到的数据给到应用层,这是通过中间层直接调用app_u0_rx_handle()函数实现,代码如下。
  1. //中间层代码 --- mid_serial.c

  2. //函数功能:该函数检测接收数据缓冲区中是否有数据。如果有数据,且接收完成,则调用
  3. //app_u0_rx_handle()函数。具体可以看“串口模块”那篇文章的相关部分。
  4. serial_u0_receiver_data_manage(void )
  5. {
  6.    if(ser0.rx.len != 0 && ser0.rx.timeout == 0)
  7.    {
  8.      SERIAL0_RECEIVER_FUNCTION(ser0.rx.fifo, ser0.rx.len);
  9.     ser0.rx.len = 0;
  10.   }
  11. }
复制代码上述代码中,SERIAL0_RECEIVER_FUNCTION()是在mid_serial.h中定义的宏。如下图所示。之所以这么做是为了把中间层代码要修改的地方集中在mid_serial.h,这样方便后续修改。
  1. //中间层 --- mid_serial.h

  2. #define  SERIAL0_RECEIVER_FUNCTION(fifo, len)  app_u0_rx_handle(fifo, len)
复制代码由于硬件层需要把数据给到中间层,因此中间层需要实现获取硬件层过来的数据接口,以便硬件层把数据给到中间层。这个接口如下。
  1. //中间层 --- mid_serial.c

  2. //函数功能:把串口寄存器过来的数据保存到串口接收缓冲区中,这个函数一般在属于硬件层
  3. //中的串口接收中断中直接调用
  4. void serial_u0_receiver_data(unsigned char rx_dt)
  5. {
  6.    ser0.rx.fifo[ser0.rx.len++] = rx_dt;
  7. }
复制代码这样,中间层用于处理接收的就有两个函数。一个是serial_u0_receiver_data_manage()函数,用于接收到完整的数据后调用应用层函数去处理数据。另一个是serial_u0_receiver_data()函数,用于硬件层调用以把串口寄存器数据给到中间层。下面把中间层的内容汇总一下。
  1. //中间层 --- mid_serial.h

  2. #define UART_TX_BUF_LENGTH_32  31
  3. #define UART_TX_BUF_LENGTH_64  63
  4. #define UART_TX_BUF_LENGTH_128 127

  5. #define UART0_TX_BUF_COUNT    UART_TX_BUF_LENGTH_64
  6. //设置发送缓冲区长度,这里的长度是64个字节
  7. #define UART0_TX_FIFO_LENGTH  (UART_TX_BUF_LENGTH_64+1)

  8. //串口发送相关的声明
  9. extern void serial_parameters_init(void );

  10. extern void serial_u0_send_manage(void );
  11. extern void serial_u0_send_str(unsigned char *ptxs);
  12. extern void serial_u0_send_hex_char(unsigned char *ptxd,unsigned char len);

  13. //串口接收相关声明
  14. extern void serial_u0_receiver_data_manage();
  15. extern void serial_u0_receiver_data(uint8_t rx_dt);
复制代码最后,我们需要在硬件层中调用serial_u0_receiver_data()函数,以便把串口寄存器内的数据保存到串口数据接收缓冲区。
  1. //硬件层 --- hal.c

  2. //串口接收中断
  3. DEFINE_ISR(UART0_RX_ISR,0x10)
  4. {
  5.   if(_t1af)
  6.   {
  7.            _t1af = 0;
  8.           serial_u0_receiver_data(_txr_rxr); //_txr_rxr为单片机的串口接收寄存器
  9.   }
  10. }
复制代码要注意,我在中断中直接调用了中间层的函数,没有使用回调函数。
这样,我们就按照三层架构的思路解决了问题(2)。同样,为了更好的理解问题(2)的三层架构,我把他们之间的调用关系汇总在下面。
  1. //应用层

  2. #include "hal.h"
  3. #include "mid_serial.h"

  4. void app_u0_rx_handle(*p,len)
  5. {
  6.    //应用层处理数据
  7. }

  8. void app_clk_2ms(void )
  9. {  
  10.   //查询串口接收缓冲区中是否有数据且接收完成,如果完成调用app_u0_rx_handle()
  11.   //函数。要注意这个函数需要定时调用。具体可看"串口模块"那篇文章。
  12.   serial_u0_receiver_data_manage();
  13. }

  14. main()
  15. {
  16.   hal_uart_init();   
  17.   serial_parameters_init();
  18.   while(1)
  19.   {
  20.      if(clk_2ms)
  21.      {
  22.        clk_2ms = 0;
  23.        app_clk_2ms();
  24.      }   
  25.   }
  26. }

  27. //中间层 --- mid_serial.h

  28. //在mid_serial.h文件中添加串口接收相关声明,他的实现在mid_serial.c中
  29. extern void serial_u0_receiver_data_manage();
  30. extern void serial_u0_receiver_data(uint8_t rx_dt);

  31. //硬件层 --- hal.c

  32. DEFINE_ISR(UART0_RX_ISR,0x10)
  33. {
  34.   if(_t1af)
  35.   {
  36.            _t1af = 0;
  37.           serial_u0_receiver_data(_txr_rxr); //_txr_rxr为单片机的串口接收寄存器
  38.   }
  39. }
复制代码最开始的两个问题通过三层架构解决了,最后看看程序文件如何组织。

clipboard123.png
工程内的文件,中间层不同功能的模块都保存在mid文件下。
clipboard124.png
工程外的文件
clipboard125.png
工程外的文件,mid文件夹中的内容
工程外app、hal等文件在"单片机程序架构 --- 二层架构"中讲过,这里就不再说了。
一般而言,把单片机程序分为三层很多时候是够用的。三层中的硬件层是对硬件功能的抽象,中间层时对应用层的抽象,最后应用层调用中间层完成自己的功能。这样,中间层、硬件层就具有比较好通用性,同时程序的架构也比较清晰,减少bug的出现。
这篇文章有点长,也不太好懂。如果想更好的理解这些东西,还需要看完之前写过的几篇文章。希望对你有用。
附上前几篇文字:单片机程序架构 --- 二层架构https://bbs.21ic.com/icview-2892166-1-1.html程序的抽象
https://bbs.21ic.com/icview-2892498-1-1.html
单片机程序架构 --- 三层架构https://bbs.21ic.com/icview-2892618-1-1.html

友情提示: 此问题已得到解决,问题已经关闭,关闭后问题禁止继续编辑,回答。
该问题目前已经被作者或者管理员关闭, 无法添加新回复
3条回答
会笑的星星
1楼-- · 2020-03-09 01:02
hobbye501
2楼-- · 2020-03-09 04:22
确实挺有深意。。。
GZZXB
3楼-- · 2020-03-09 08:23
看了楼主的注册时间居然是刚注册没几天,就愿意分享一些这些心得实在难得。  楼主是否可以放上pdf,这样就可以download下来给公司的新人当教材了。  希望楼主能继续分享写一些

一周热门 更多>