尽管目前工业以太网已经相当普及,但在工控领域仍然存在大量使用UART通过RS485和RS422组网的设备和控制器,导致含有多UART的嵌入式系统仍有较大市场需求。意法半导体和兆易创新等主流微控制器(MCU)厂商都有10个以上UART的器件,但在很多场景下仍然无法覆盖所有应用场景。另外,对于主控单元是微处理器(MPU,能运行Linux)的嵌入式系统,UART口一般较少,就不得不使用16C550/16C554这类扩展芯片来实现多UART。
用FPGA来实现可定制的多UART口扩展是一种不错的解决方案。其中,在Zynq上通过AXI总线扩展多个UART难度不高,但限制了主控单元的使用,且成本较高。我趁着这个国庆长假在低成本的国产FPGA/CPLD上实现了一种基于FSMC接口(GD32上称为EXMC接口)的多串口控制器,适用于所有兼容ISA总线接口的嵌入式MCU/MPU系统。可以根据应用场景任意裁剪UART的个数、FIFO缓冲的深度。
以下原创内容欢迎网友转载,但请注明出处: https://www.cnblogs.com/helesheng
一、整体结构
我设计的多UART控制器采用外设地址映射模式思路,FPGA模拟成一个8位的FSMC设备和处理器(如STM32)通信。其中,每个UART口占用数据接收寄存器地址(UARTx_RX_Reg_Addr,小写x可以代表任意串口编号的阿拉伯数字,下同)、接收缓冲长度寄存器地址(UART1_RX_LEN_Reg_Addr)、数据发送寄存器地址(UARTx_TX_Reg_Addr)以及发送缓冲长度寄存器地址(UARTx_TX_LEN_Reg_Addr)共四个字节的地址;另外,每个UART口还占用了发送状态寄存器(STAT_TX_Addr)和接收状态寄存器(STAT_RX_Addr)中的各一个位,来表示该UART当前是否处于发送或接收状态。
其中,每个UART的数据发送和接收寄存器在逻辑概念上虽然只占用的一个字节的地址,但在物理上却映射到一组接收FIFO和发送FIFO的一个端口。以接收FIFO为例:每当UART口收到一个字节的数据A时,FPGA就会将该数据压入接收FIFO中,而处理器也不需要立即将刚收到的数据读走,因为即使处理器没有及时地在下次收到数据B之前读走该数据,FIFO还是能够缓存这个新数据。而当处理器腾出时间处理该UART口的接收数据时,还是能从同一个地址(UARTx_RX_Reg_Addr)按照收到这些数据的顺序A->B->C....依次读取他们。这种方式的优势有二:
1、相对于嵌入式处理器,UART属于低速设备,对于接收而言,增加了FIFO缓冲的UART控制器无需处理器过于频繁的查询,只需要间隔相当一段时间查看一下FIFO中是否缓存了数据即可;对于发送而言,增加了FIFO缓冲的控制器也可以一次缓冲多个需要发送的字节,降低处理器关注的频率。
2、发送和接收缓冲分别都只占用一个字节的地址空间,即可实现任意长度数据包的收发。当然,代价是处理器只能顺序访问缓冲区,而无法实现随机访问,但这对于需要严格按顺序读写的UART口而言,这并不是问题。
下面的代码是我在作为处理器的STM32代码中定义的UART相关寄存器地址:
1 #define STAT_RX_Addr ((unsigned int)0x60000000) //FPGA模拟的uart口接收状态寄存器地址
2 #define STAT_TX_Addr ((unsigned int)0x60010000) //FPGA模拟的uart口接发送态寄存器地址
3 #define UART1_RX_Reg_Addr ((unsigned int)0x60020000) //fpga模拟的uart1口接收数据地址
4 #define UART1_TX_Reg_Addr ((unsigned int)0x60030000) //fpga模拟的uart1口发送数据地址
5 #define UART1_RX_LEN_Reg_Addr ((unsigned int)0x60040000) //fpga模拟的uart1口接收缓冲区中数据长度的地址
6 #define UART1_TX_LEN_Reg_Addr ((unsigned int)0x60050000) //fpga模拟的uart1口发送缓冲区中数据长度的地址
7 #define UART2_RX_Reg_Addr ((unsigned int)0x60060000) //fpga模拟的uart2口接收数据地址
8 #define UART2_TX_Reg_Addr ((unsigned int)0x60070000) //fpga模拟的uart2口发送数据地址
9 #define UART2_RX_LEN_Reg_Addr ((unsigned int)0x60080000) //fpga模拟的uart2口接收缓冲区中数据长度的地址
10 #define UART2_TX_LEN_Reg_Addr ((unsigned int)0x60090000) //fpga模拟的uart2口发送缓冲区中数据长度的地址
UART寄存器地址(STM32侧)
FPGA代码中定义的UART相关寄存器地址:
1 // 定义地址
2 parameter ADDR_RX_STATE = 5'd0; //状态寄存器高字节地址,对应ARM地址0x60000000
3 parameter ADDR_TX_STATE = 5'd1; //状态寄存器低字节地址,对应ARM地址0x60010000
4 parameter ADDR_UART1_RX = 5'd2; //uart1接收数据端口地址,对应ARM地址0x60020000
5 parameter ADDR_UART1_TX = 5'd3; //uart1发送数据端口地址,对应ARM地址0x60030000
6 parameter ADDR_UART1_RX_LEN = 5'd4; //uart1接收缓冲中待读取数据数量的读取端口地址,对应ARM地址0x60040000
7 parameter ADDR_UART1_TX_LEN = 5'd5; //uart1发送缓冲中待发送数据数量的读取端口地址,对应ARM地址0x60050000
8 parameter ADDR_UART2_RX = 5'd6; //uart2接收数据端口地址,对应ARM地址0x60060000
9 parameter ADDR_UART2_TX = 5'd7; //uart2发送数据端口地址,对应ARM地址0x60070000
10 parameter ADDR_UART2_RX_LEN = 5'd8; //uart1接收缓冲中待读取数据数量的读取端口地址,对应ARM地址0x60080000
11 parameter ADDR_UART2_TX_LEN = 5'd9; //uart1发送缓冲中待发送数据数量的读取端口地址,对应ARM地址0x60090000
UART寄存器地址(FPGA侧)
二、FPGA中八位并行接口(FSMC)设备端的实现
1、读写时钟控制信号的产生
下图是我在STM32手册中截取的配置成SRAM的读写模式时FSMC接口的读写时序。

图1 FSMC(SRAM模式)读时序

图2 FSMC(SRAM模式)写时序
对于100pin的STM32而言,并不拥有全部26根地址线,而只有A16-A23共8根,以及一根自动产生的片选线NCE1/NCE2(控制地址范围达到全部4G寻址空间的四分之一)。
FPGA侧为了实现FSMC的设备端接口,核心要求是能够在写使能NWE和读使能NOE控制下在FPGA中实现数据的锁存和输出。有两种合理的技术路线:其一,使用NWE或NOE作为数据寄存器的锁存时钟;其二,用FPGA系统时钟作为数据寄存器的锁存时钟,NWE或NOE作锁存器读写的使能信号。
第一种思路最直接,但问题是缓冲FIFO的存储时钟只能由STM32的读、写动作产生,但一般FIFO IP在真正进行读写之前也需要时钟信号来完成初始化。如果采用第一种技术路线,势必需要STM32作几组无意义的"空读写",以产生必要的初始化时钟。另外,也可能由于STM32到FPGA之间的PCB走线造成时序约束难度的增加,并提升外部高频信号干扰的可能,因此我没有选择这条技术路线。第二种思路的难点在于NWE或NOE信号低电平期间,可能产生多个FPGA的系统时钟,从而造成对同字节数据被缓冲FIFO看做多个数据,进而被多次读或写。
为实现第一种技术路线,我用如下代码将长度不确定的NWE或NOE信号转换为一个长度为1个系统时钟的正脉冲作为缓冲FIFO的读写使能控制信号。完美的解决了单次STM32读写造成多次缓冲FIFO读写的问题。
1 /////读端口控制线//////
2 assign en_fsmc_oe = (fsmc_a[21] == 1'b0) && (rd_n == 1'b0) && (cs_n == 1'b0);
3 wire rd_uart1_rx_fifo;//用于mcu读取接收缓冲fifo的使能信号
4 assign rd_uart1_rx_fifo = (fsmc_a[20:16] == ADDR_UART1_RX) && en_fsmc_oe;//MCU发出的读取uart1接收数据的使能信号
5 reg[1:0] prv_rd_uart_rx_fifo_reg;
6 always @(posedge clk_in_50 or negedge rst_n)//串口模块接收标志信号是个正脉冲,在下降沿数据才完全准备好。对其进行延迟
7 begin
8 if(!rst_n)
9 prv_rd_uart1_rx_fifo_reg[1:0] <= 2'b00;
10 else begin
11 prv_rd_uart1_rx_fifo_reg[1:0] <= {prv_rd_uart1_rx_fifo_reg[0] , rd_uart1_rx_fifo};
12 end
13 end
14 wire rd_uart1_rx_fifo_1ck;//MCU发出的读取uart接收数据的信号的第一个主频周期产生一个主频周期的高电平
15 assign rd_uart1_rx_fifo_1ck = prv_rd_uart1_rx_fifo_reg[0] & (prv_rd_uart1_rx_fifo_reg[1] ^ prv_rd_uart1_rx_fifo_reg[0]);
读端口使能控制信号rd_uart_rx_fifo_1ck
上述代码的核心思路是检测NWE或NOE信号上出现的下降沿(此处被语句:assign en_fsmc_oe = (fsmc_a[21] == 1'b0) && (rd_n == 1'b0) && (cs_n == 1'b0);变成了en_fsmc_oe和rd_uart1_rx_fifo信号的上升沿),方法是用移位寄存器缓冲两个FPGA系统时钟有效边沿时刻的信号,并用异或判断二者是否相反,最后用与运算判断是上升沿还是下降沿。这种方法有效的避免了NWE和NOE持续时间超过一个FPGA系统时钟周期,以及上升、下降沿混淆的问题。但应注意:由于STM32产生的NWE或NOE信号与FPGA系统时钟不同步,wr_data_1ck信号上持续的1个时钟周期的高电平最早可能开始于NWE或NOE下降沿出现的瞬间,最迟可能开始于NWE或NOE下降沿出现一个时钟周期之后------因此由图1可知,读数据建立DATAST应该大于1或2(@72MHz HCLK)。
2、双向数据线数据输出及高阻态控制
其核心思想是设计一个组合逻辑数据多路器,将不同地址输出的数据连接到out_data[7:0],并用输出使能信号en_fsmc_oe实现高阻态控制。
1 reg[7:0] out_data;//用于输出的数据线(由于要使用always语句这里被定义了reg类型,但实际是wire)
2 assign fsmc_d[7:0] = (en_fsmc_oe == 1'b1) ? out_data : 8'bzzzz_zzzz;//双向的数据总线
3 always @(*) begin
4 case (fsmc_a[20:16])
5 ADDR_RX_STATE : out_data[7:0] = PERI_UART_RX_STATE[7:0];
6 ADDR_TX_STATE : out_data[7:0] = PERI_UART_TX_STATE[7:0];
7 ADDR_UART1_RX : out_data[7:0] = uart1_rx_fifo_out_wire[7:0];
8 ADDR_UART1_RX_LEN : out_data[7:0] = uart1_rx_data_len[7:0];
9 ADDR_UART1_TX_LEN : out_data[7:0] = uart1_tx_data_len[7:0];
10 ADDR_UART2_RX : out_data[7:0] = uart2_rx_fifo_out_wire[7:0];
11 ADDR_UART2_RX_LEN : out_data[7:0] = uart2_rx_data_len[7:0];
12 ADDR_UART2_TX_LEN : out_data[7:0] = uart2_tx_data_len[7:0];
13 // ... 其他地址 ...
14 default: out_data[7:0] = 8'h00; // 避免锁存器
15 endcase
16 end
读端口输出电路设计
3、收、发缓冲FIFO的实现
我使用了安路低成本的小精灵2(EF2系列)价格最低的一款CPLD/FPGA来实现我的设计,下图是TD(Tang Dynasty)开发环境中FIFO的图形化配置界面,每扩展一个深度为256字节的UART需要2个这样的FIFO各(分别作发送和接收缓冲)。选择用片上的9K块RAM资源来实现,而没有选择用LE中的DRAM(离散RAM),原因是为了降低应用对宝贵LE资源的占用,但由于258*8的结构其实只使用了每个EMB9K中RAM资源的四分之一,后续复杂应用还有较大的缓冲深度升级余地。对资源最少的EF2L15而言,由于只有6个EMB9K,最多只能实现3个UART口。若需更多UART,需要选择更大的可编程芯片。

图3 256字节的缓冲FIFO
下面是发送和接收FIFO的例化代码。
1 UART_RX_FIFO uart_rx_fifo(
2 .rst(!rst_n), //asynchronous port,active hight
3 .clkw(clk_in_50), //写时钟
4 .clkr(clk_in_50), //read clock
5 .we(uart_rx_flag), //write enable,active hight
6 .di(uart_rx_fifo_in_wire[7:0]), //write data
7 .re(rd_uart_rx_fifo_1ck), //read enable,active hight
8 .dout(uart_rx_fifo_out_wire[7:0]), //read data
9 .valid(), //read data valid flag
10 .full_flag(), //fifo full flag
11 .empty_flag(uart_rx_fifo_empty), //fifo empty flag
12 .afull(), //fifo almost full flag
13 .aempty(), //fifo almost empty flag
14 .wrusedw(rx_data_len[7:0]), //stored data number in fifo
15 .rdusedw() //available data number for read
16 ) ;
17 UART_TX_FIFO uart_tx_fifo(
18 .rst(!rst_n), //asynchronous port,active hight
19 .clkw(clk_in_50), //写时钟
20 .clkr(clk_in_50), //read clock
21 .we(wr_uart_tx_fifo_1ck), //write enable,active hight
22 .di(fsmc_d[7:0]), //write data直接将数据总线数据写入发送fifo
23 .re(uart_tx_fifo_rd_en_1ck),//.re(!rst_n),// //read enable,active hight
24 .dout(uart_tx_data_wire[7:0]), //read data
25 .valid(), //read data valid flag
26 .full_flag(), //fifo full flag
27 .empty_flag(uart_tx_fifo_empty), //fifo empty flag
28 .afull(), //fifo almost full flag
29 .aempty(), //fifo almost empty flag
30 .wrusedw(tx_data_len[7:0]), //stored data number in fifo
31 .rdusedw() //available data number for read
32 ) ;
接收和发送缓冲FIFO例化
4、UART模块的实现
TD自带UART的IP,配置界面如下图所示。其中模块时钟为50MHz,不建议大家为了省电使用更低频率:实验显示使用低频时钟后,UART连续接收多个字节数据包时可能出现少量字节识别错误。

图4 UART模块配置
下面是UART的例化代码。
1 UART uart_inst
2 (
3 .clk(clk_in_50),//UART时钟
4 .rst_n(rst_n),
5 .rxd(rxd),//rx管脚
6 .tx_data(uart_tx_data_wire[7:0]),//要发送的数据
7 .tx_en(uart_tx_en_1ck),//启动uart发送
8 .rx_data(uart_rx_fifo_in_wire[7:0]),//收到的数据
9 .rx_err(),
10 .rx_vld(uart_rx_flag),//收到数据标志
11 .tx_rdy(uart_tx_rdy_wire),
12 .txd(txd)//tx管脚
13 );
例化UART IP代码
该模块收、发时序如下图所示。

图5 UART模块接收数据时序

图6 UART模块发送数据时序
正确完成一个字节数据的接收后,模块将在rx_vld上产生1个时钟周期的高电平,正好用于接收缓冲FIFO的写使能信号,嵌入式处理器可以在合适的时间从接收FIFO中读取之前收到的数据。发送控制信号tx_en的生成逻辑比较麻烦,它需要在发送FIFO非空的情况下不断产生长度为1个时钟周期的正脉冲。我使用一个有限状态机FSM来产生所需的读取发送缓冲FIFO的信号和使能UART发送的tx_en信号。
1 always @(posedge clk_in_50 or negedge rst_n)
2 begin
3 if(!rst_n)
4 uart_tx_state_reg[2:0] <= WAIT_STATE;
5 else begin
6 case(uart_tx_state_reg[2:0])
7 WAIT_STATE:
8 begin
9 if((!uart_tx_fifo_empty)&&(uart_tx_rdy_wire))//发送缓冲区不为空,且串口准备好发送
10 uart_tx_state_reg[2:0] <= READ_FIFO_STATE;//就进入读取缓冲区状态
11 else
12 uart_tx_state_reg[2:0] <= WAIT_STATE;//否则继续等待
13 end
14 READ_FIFO_STATE:
15 begin
16 uart_tx_state_reg[2:0] <= WRITE_UART_REG_STATE;
17 end
18 WRITE_UART_REG_STATE:
19 begin
20 uart_tx_state_reg[2:0] <= WAIT_STATE;
21 end
22 default: begin
23 uart_tx_state_reg[2:0] <= WAIT_STATE;
24 end
25 endcase
26 end
27 end
28
29 wire uart_tx_fifo_rd_en_1ck;//uart发送缓冲区读取使能信号,只能持续一个时钟周期
30 assign uart_tx_fifo_rd_en_1ck = uart_tx_state_reg[1];//状态寄存器的第二个位,对应从发送FIFO读取数据
31 wire uart_tx_en_1ck;//启动uart发送信号,持续1个时钟周期
32 assign uart_tx_en_1ck = uart_tx_state_reg[2];//状态寄存器的第三个位刚好对应使能uart发送数据
读取发送缓冲和UART发送使能控制状态机
缺省状态为空闲状态WAIT_STATE,只有当UART发送完成(uart_tx_rdy_wire为1)且发送缓冲FIFO中有数据时(uart_tx_fifo_empty为0),状态机才进入读取发送缓冲FIFO状态(READ_FIFO_STATE),以及启动UART发送状态(WRITE_UART_REG_STATE),而后两个状态都只固定的停留1个时钟周期。对应的标志uart_tx_fifo_rd_en_1ck和uart_tx_en_1ck则被分别连接到了前文提到的发送缓冲FIFO模块和UART模块。
5、状态寄存器内容的更新
状态寄存器中可以存放任何UART控制器想与嵌入式处理器沟通的内容。为防止不同状态位延迟不同造成的竞争和冒险,我采用一组寄存器来锁存状态,锁存时钟就是系统时钟。
1 //更新状态寄存器
2 always @(posedge clk_50_wire or negedge rst_n)
3 begin
4 if(!rst_n) begin
5 PERI_UART_RX_STATE[7:0] <= 8'h00;
6 PERI_UART_TX_STATE[7:0] <= 8'h00;
7 end
8 else begin
9 PERI_UART_RX_STATE[7:0] <= {6'h00 , (!uart2_rx_fifo_empty) , (!uart1_rx_fifo_empty)};//将状态标志统一到状态寄存,接收状态寄存为1表示,缓冲区中有待读取的数据
10 PERI_UART_TX_STATE[7:0] <= {6'h00 , uart2_tx_rdy , uart1_tx_rdy};
11 end
12 end
UART状态寄存器的实现
其中PERI_UART_RX_STATE[7:0] 等寄存器地址,请参考本文开始阶段的定义。
6、FPGA时序约束
本文所述电路系统频率仅为50MHz,且作为UART模块外部IO接口频率在1MHz以下,基本不需要太严格的时序约束。我只对输入时钟和PLL派生出的50MHz时钟进行了简单约束如下:
1 create_clock -name clk_in_10 -period 100 -waveform {0 50} [get_ports {clk_in_10}]
2 #derive_clocks -period 20
3 derive_pll_clocks
4 rename_clock -name {clk_50_wire} -source [get_ports {clk_in_10}] -master_clock {clk_in_10} [get_pins {pll_inst/pll_inst.clkc[0]}]
时钟约束
三、嵌入式处理器控制代码
我在STM32中移植了RTOS,其中每个任务管理一个UART口,定时查询接收状态寄存器STAT_RX_Addr在有数据时不断读取接收FIFO,随后将收到的数据再发给同一个串口。这样用PC和串口调试助手就可以观察每个串口的收发是否正常了。
1 void TaskUART1(void *pdata)
2 {
3 unsigned char state_rx, rx_len;
4 unsigned int i,j;
5 unsigned char rx_buff[20];
6 while(1)
7 {
8 OSTimeDly(30);
9 state_rx = *(volatile unsigned char *) (STAT_RX_Addr);
10 if((state_rx & 0x01) != 0x00)//UART有数据的情况下就读取数据
11 {
12 i = 0;
13 rx_len = *(volatile unsigned char *) (UART1_RX_LEN_Reg_Addr);//读取FPGA缓冲中数据长度寄存器
14 while((state_rx & 0x01) != 0x00)//缓冲区不空就继续读取
15 {
16 rx_buff[i] = *(volatile unsigned char *) (UART1_RX_Reg_Addr);
17 i++;
18 state_rx = *(volatile unsigned char *) (STAT_RX_Addr);
19 }
20 *(volatile unsigned char *) UART1_TX_Reg_Addr = i;//把读取到的数据量,发还给同一个串口
21 //把收到的数据再打印回来
22 for(j = 0 ; j < i ; j++)
23 *(volatile unsigned char *) UART1_TX_Reg_Addr = rx_buff[j];
24 }
25 }
26 }
管理单个UART口的任务代码举例
四、总结
上述方法实现的UART在低成本的EF2L15测试能够稳定工作,每个UART约占用400-500个LE之外,还占用2个EMB9K。