
1. 项目概述从PS/2接口到串口输出的FPGA键盘解码器最近在整理一些FPGA的入门项目翻到了之前做的一个PS/2键盘解码的小玩意儿。这个项目的核心目标很直接用一块FPGA开发板接上一个老式的PS/2键盘当你按下键盘上的字母键时FPGA能识别出是哪个键然后通过串口把对应的ASCII码发送到电脑上在串口调试助手里实时显示出来。听起来像是把两个“古董”协议PS/2和UART用现代的可编程逻辑连了起来但恰恰是这种“桥梁”项目最能锻炼你对时序逻辑、协议解析和系统集成的理解。PS/2接口虽然现在用得少了但在嵌入式系统和一些工控场景里还能见到它的通信协议本身非常经典是一种同步串行协议。而UART串口更是嵌入式开发者的“老朋友”调试必备。用FPGA来实现这两个协议的对接你不仅能搞懂它们各自是怎么工作的更能深刻体会硬件描述语言Verilog如何“安排”硬件资源去精准地捕捉信号、处理数据。这个项目适合已经学过Verilog基础语法、搞过LED闪烁和按键消抖想要挑战一下具体通信协议实现的同学。下面我就把当时的设计思路、代码细节以及调试过程中踩过的坑系统地梳理一遍。2. 核心设计思路与模块划分整个系统的数据流非常清晰PS/2键盘产生按键扫描码 - FPGA接收并解码 - 将扫描码转换为ASCII码 - 通过UART TX发送出去。基于这个流程我采用了经典的“自顶向下”模块化设计将系统划分为三个核心功能模块和一个顶层整合模块。2.1 顶层模块系统的“接线图”顶层模块我命名为ps2_key不实现复杂逻辑它的主要职责是“接线”和“调度”。它定义了整个系统对外的引脚与物理世界交互的接口和对内的互连模块间的数据与控制流。我们来看一下它的代码骨架module ps2_key( input clk, // 50MHz 系统主时钟 input rst_n, // 低电平有效的全局复位信号 input ps2k_clk, // PS/2接口的时钟线 input ps2k_data, // PS/2接口的数据线 output rs232_tx // UART发送数据线 ); // 内部连线声明 wire [7:0] ps2_byte; // 从键盘接收到的1字节键值扫描码 wire ps2_state; // 按键状态标志高电平表示有新的有效键值 wire bps_start; // 波特率发生器启动信号 wire clk_bps; // 波特率时钟用于UART发送的位定时 // 实例化三个子模块 ps2scan u_ps2scan ( .clk(clk), .rst_n(rst_n), .ps2k_clk(ps2k_clk), .ps2k_data(ps2k_data), .ps2_byte(ps2_byte), .ps2_state(ps2_state) ); speed_select u_speed_select ( .clk(clk), .rst_n(rst_n), .bps_start(bps_start), .clk_bps(clk_bps) ); my_uart_tx u_my_uart_tx ( .clk(clk), .rst_n(rst_n), .clk_bps(clk_bps), .rx_data(ps2_byte), // 注意这里将接收到的键值作为发送数据 .rx_int(ps2_state), // 按键状态作为发送启动信号 .rs232_tx(rs232_tx), .bps_start(bps_start) ); endmodule设计要点解析接口定义clk和rst_n是数字系统的标配。ps2k_clk和ps2k_data需要连接到FPGA的普通IO引脚注意PS/2接口是5V电平而多数FPGA是3.3V LVCMOS电平中间可能需要电平转换或使用带钳位保护的IO有些FPGA引脚兼容5V输入。模块互联ps2_state信号是关键的控制流。当ps2scan模块检测到有键按下时它拉高ps2_state这个信号同时连接到my_uart_tx模块的rx_int接收中断这里复用为发送请求和speed_select模块的bps_start。这意味着一旦有键值就同时启动波特率时钟生成和UART发送流程。数据流解码后的8位键值ps2_byte直接传递给UART发送模块作为待发送数据rx_data。注意这种设计是一种“触发即发送”的简单模式。它没有缓冲区如果按键速度过快快于UART发送一个字节的时间会导致数据丢失。对于键盘输入这种低速场景通常够用但这是理解后续可能优化方向如添加FIFO的基础。2.2 PS/2扫描模块协议解码的核心这是整个项目最核心也最有趣的部分ps2scan模块负责与PS/2键盘对话理解它发来的每一帧数据。PS/2协议是双向的但这里我们只实现主机FPGA接收设备键盘数据的功能。2.2.1 PS/2协议基础与硬件连接PS/2接口使用6针的Mini-DIN连接器但我们实际只用到其中4针VCC5V、GND、Data数据线、Clock时钟线。时钟线由键盘产生频率通常在10kHz到16.67kHz之间。数据传输格式是每帧11位1位起始位总是0、8位数据位LSB先发、1位奇校验位、1位停止位总是1。时钟下降沿锁存数据。在FPGA侧我们需要用两个普通IO口来连接ps2k_clk和ps2k_data。由于键盘时钟是异步于FPGA系统时钟的直接用它来驱动FPGA内部的时序逻辑会引入亚稳态问题。因此标准的做法是使用FPGA的高速系统时钟如50MHz来对这两根信号线进行同步和边沿检测。2.2.2 代码逐行解析与设计考量让我们深入ps2scan模块的代码看看如何用Verilog实现上述逻辑。module ps2scan( input clk, // 50MHz系统时钟 input rst_n, input ps2k_clk, // 来自键盘的时钟 input ps2k_data, // 来自键盘的数据 output reg [7:0] ps2_byte, // 解码出的键值 output reg ps2_state // 按键状态标志 );第一部分时钟同步与边沿检测这是处理异步信号的黄金步骤。reg ps2k_clk_r0, ps2k_clk_r1, ps2k_clk_r2; wire neg_ps2k_clk; // 下降沿标志 always (posedge clk or negedge rst_n) begin if (!rst_n) begin ps2k_clk_r0 1b1; // 注意空闲时PS/2时钟线为高电平 ps2k_clk_r1 1b1; ps2k_clk_r2 1b1; end else begin ps2k_clk_r0 ps2k_clk; // 第一级同步 ps2k_clk_r1 ps2k_clk_r0; // 第二级同步消除亚稳态 ps2k_clk_r2 ps2k_clk_r1; // 用于边沿检测 end end assign neg_ps2k_clk ps2k_clk_r2 ~ps2k_clk_r1; // 检测下降沿为什么用三级寄存器ps2k_clk_r0和ps2k_clk_r1构成一个经典的“双寄存器同步器”极大降低了来自异步时钟域的信号ps2k_clk在进入FPGA时钟域clk时引发亚稳态的概率。ps2k_clk_r2则是为了干净地检测边沿。边沿检测逻辑neg_ps2k_clk r2 ~r1。当上一拍r1为高当前拍r2为低时即r11, r20说明在clk的采样视角下ps2k_clk出现了从高到低的变化我们便产生一个时钟周期宽的高电平脉冲作为下降沿标志。这个脉冲将作为我们读取数据位的使能信号。第二部分数据位采集与帧组装这是状态机思想的体现用一个计数器num来追踪当前正在接收的是第几位。reg [3:0] num; // 0~10共11位 reg [7:0] temp_data; // 临时存储接收到的8位数据 always (posedge clk or negedge rst_n) begin if (!rst_n) begin num 4d0; temp_data 8d0; end else if (neg_ps2k_clk) begin // 只在时钟下降沿处理 case (num) 4d0: num num 1b1; // 起始位忽略 4d1: begin num num 1b1; temp_data[0] ps2k_data; end // LSB 4d2: begin num num 1b1; temp_data[1] ps2k_data; end ... // 类似地处理第2到第7位 (num3~8) 4d9: begin num num 1b1; end // 奇偶校验位本例忽略 4d10: begin num 4d0; end // 停止位接收完毕计数器复位 default: num 4d0; endcase end endLSB First注意赋值顺序num1时采集的是temp_data[0]这符合PS/2协议LSB最低位先发的约定。采样点我们在检测到ps2k_clk下降沿时采样ps2k_data。根据协议数据在时钟下降沿前后是稳定的此时采样最可靠。忽略校验位为了简化本例没有实现奇偶校验。在产品级设计中校验是必要的可以增加数据的可靠性。第三部分键值处理与状态生成PS/2键盘的按键消息是“通码Make”和“断码Break”组合。通常按下时发送一个通码如A键是0x1C释放时先发送一个0xF0再跟一个通码。我们需要区分按下和释放。reg key_f0; // 断码标志 reg [7:0] ps2_byte_r; // 存储最终有效的键值通码 always (posedge clk or negedge rst_n) begin if (!rst_n) begin key_f0 1b0; ps2_state_r 1b0; ps2_byte_r 8d0; end else if (num 4d10) begin // 一帧11位接收完成 if (temp_data 8hf0) begin key_f0 1b1; // 收到断码前缀置位标志 end else begin if (!key_f0) begin // 之前没有收到F0说明是按下事件 ps2_state_r 1b1; // 产生按键有效脉冲 ps2_byte_r temp_data; // 锁存键值 end else begin // 之前收到了F0现在是通码说明是释放事件 ps2_state_r 1b0; key_f0 1b0; // 清除断码标志 end end end else begin ps2_state_r 1b0; // 状态信号只维持一个处理周期 end end状态机逻辑key_f0标志位构成了一个简单的状态机。状态0等待收到0xF0进入状态1期待通码在状态1下收到通码则识别为释放事件清除状态。在状态0下收到非0xF0的通码则识别为按下事件。脉冲信号ps2_state_r在识别到按下事件时仅在一个时钟周期内拉高。这个脉冲信号用于通知其他模块如UART“有新的键值可用了”第四部分扫描码到ASCII码的转换这是应用层逻辑。PS/2键盘输出的是位置扫描码Scan Code Set 2我们需要将其映射为ASCII码。本例只映射了大写字母A-Z。reg [7:0] ps2_asci; // 转换后的ASCII码 always (*) begin // 使用组合逻辑键值改变立即转换 case (ps2_byte_r) 8h1c: ps2_asci 8h41; // A - A (0x41) 8h32: ps2_asci 8h42; // B - B ... // 其他字母映射 8h3a: ps2_asci 8h4d; // M - M default: ps2_asci 8h00; // 非字母键输出空或可定义其他值 endcase end assign ps2_byte ps2_asci; assign ps2_state ps2_state_r;组合逻辑映射使用always (*)块实现一个查找表LUT。当ps2_byte_r变化时ps2_asci立即更新。扩展性这个case语句很容易扩展。要支持小写字母需要结合Caps Lock或Shift键的状态要支持数字和符号键只需添加更多的映射条目。实操心得在编写这部分代码时最大的坑在于PS/2时钟的消抖和亚稳态处理。如果同步寄存器级数不够或者边沿检测逻辑不严谨在键盘时钟边沿附近采样数据极易导致错位接收到的数据全是乱的。务必确保neg_ps2k_clk脉冲的干净和准确。另外扫描码表一定要查对不同键盘或设置可能略有差异但Set 2是PC最常用的。3. 波特率生成与UART发送模块详解PS/2解码模块产出了数据和触发信号下一步就是通过串口将其发送出去。UART发送部分由两个模块协作完成speed_select波特率时钟生成器和my_uart_tx发送器。3.1 波特率时钟生成模块UART通信的核心是精准的波特率。我们需要从FPGA的高频系统时钟如50MHz中分频产生一个符合波特率要求的低频时钟信号clk_bps。这个时钟的上升沿或下降沿应对齐到每个数据位的中间时刻进行采样或输出。module speed_select( input clk, // 50MHz input rst_n, input bps_start, // 启动信号高电平有效 output clk_bps // 波特率时钟脉冲 );设计思路通常我们不生成连续的波特率时钟而是生成一个周期与波特率时钟周期相同、但宽度仅为一个系统时钟周期的脉冲信号。这个脉冲在需要发送/接收每一位数据时出现一次作为“节拍”。假设系统时钟clk频率为clk_freq 50_000_000 Hz目标波特率bps 9600。波特率周期T_bps 1 / 9600 ≈ 104166.67 ns系统时钟周期T_clk 1 / 50e6 20 ns每个波特率时钟周期包含的系统时钟数N T_bps / T_clk 104166.67 / 20 ≈ 5208.33取整为5208。那么我们需要一个计数器从0计数到5207共5208个周期当计数器达到5207时产生一个脉冲clk_bps拉高一个clk周期同时计数器归零重新开始计数。这个脉冲的周期就是5208 * 20ns 104160ns对应的波特率约为9600.6误差极小完全满足要求。parameter BPS_PARA 13d5208; // 50MHz / 9600 ≈ 5208 reg [12:0] cnt; reg clk_bps_r; always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt 13d0; clk_bps_r 1b0; end else if (bps_start) begin // 只有启动信号有效时才计数 if (cnt BPS_PARA - 1) begin cnt 13d0; clk_bps_r 1b1; // 产生一个时钟周期的脉冲 end else begin cnt cnt 1b1; clk_bps_r 1b0; end end else begin // 没有启动信号计数器保持 cnt 13d0; clk_bps_r 1b0; end end assign clk_bps clk_bps_r;参数化设计BPS_PARA使用参数定义方便切换不同的波特率如115200、19200等。计算方法是BPS_PARA clk_freq / bps。门控计数计数器仅在bps_start为高时工作。当PS/2模块产生ps2_state脉冲时bps_start被拉高启动波特率时钟生成。发送模块会在发送完一个字节后将bps_start拉低停止计数节省功耗。3.2 UART发送器模块发送器模块在波特率时钟clk_bps的节拍下将8位并行数据rx_data按照UART帧格式1位起始位8位数据位1位停止位串行输出到rs232_tx线上。module my_uart_tx( input clk, input rst_n, input clk_bps, // 波特率时钟脉冲 input [7:0] rx_data, // 待发送数据来自PS/2键值 input rx_int, // 发送启动信号连接ps2_state output reg rs232_tx, output reg bps_start // 控制波特率生成器的启停 ); reg [3:0] num; // 发送位计数器 (0~10) reg [7:0] tx_data; // 发送数据寄存器 always (posedge clk or negedge rst_n) begin if (!rst_n) begin bps_start 1b0; rs232_tx 1b1; // 空闲时TX为高电平 num 4d0; tx_data 8d0; end else if (rx_int) begin // 收到发送请求 bps_start 1b1; // 启动波特率时钟 tx_data rx_data; // 锁存待发送数据 end else if (clk_bps) begin // 波特率时钟脉冲到来 case (num) 4d0: begin rs232_tx 1b0; // 起始位 num num 1b1; end 4d1, 4d2, 4d3, 4d4, 4d5, 4d6, 4d7, 4d8: begin rs232_tx tx_data[num-1]; // 发送数据位LSB first num num 1b1; end 4d9: begin rs232_tx 1b1; // 停止位 num num 1b1; end 4d10: begin bps_start 1b0; // 发送完毕关闭波特率时钟 num 4d0; // rs232_tx 保持高电平空闲 end endcase end end endmodule发送状态机num计数器清晰地定义了发送过程的11个状态0起始位1-8数据位9停止位10结束。数据锁存在rx_int即ps2_state有效时立刻锁存rx_data到tx_data。这是必须的因为rx_data可能随时变化而发送过程需要持续约10个波特率周期。LSB FirstUART协议也是LSB先发。注意发送数据位时的索引tx_data[num-1]。当num1时发送tx_data[0]。流程控制模块通过bps_start信号控制波特率生成器。发送开始时启动发送完毕后停止实现了按需生成时钟降低了系统动态功耗。注意事项UART发送模块的clk_bps信号必须非常干净且严格对准每一位数据的中间。如果clk_bps的脉冲产生有偏差如计数器初值设置不当会导致发送数据的位宽不一致在接收端产生误码。仿真时一定要仔细检查clk_bps与rs232_tx的时序关系。4. 系统集成、仿真与板级调试实录模块代码写完并不意味着成功仿真和调试才是重头戏。这里分享我从仿真到上板调试的全过程。4.1 测试平台的搭建与仿真我使用ModelSim或Vivado自带的仿真工具进行测试。首先需要编写一个testbench来模拟键盘的行为。1. 模拟PS/2键盘发送数据在testbench中我需要模拟产生PS/2协议的时钟和数据信号。例如模拟发送一个A键的通码8‘h1C。// 示例发送 8‘h1C (二进制 00011100 LSB先发为 00111000) task send_ps2_byte; input [7:0] data; integer i; reg parity; begin // 计算奇校验位 (本例忽略但模拟时应包含) parity ^data; // 按位异或计算奇偶性 // 产生起始位 (0) ps2k_data 0; #50000 ps2k_clk 0; // 时钟低电平 #30000 ps2k_clk 1; // 产生下降沿对应用户代码的采样点 #20000; // 保持高电平一段时间 // 发送8位数据 for (i0; i8; ii1) begin ps2k_data data[i]; #50000 ps2k_clk 0; #30000 ps2k_clk 1; #20000; end // 发送校验位 (本例代码忽略但模拟时发送) ps2k_data parity; #50000 ps2k_clk 0; #30000 ps2k_clk 1; #20000; // 发送停止位 (1) ps2k_data 1; #50000 ps2k_clk 0; #30000 ps2k_clk 1; #20000; // 总线释放 ps2k_data 1bz; ps2k_clk 1bz; end endtask在initial块中调用send_ps2_byte(8‘h1C);来模拟按下A键。2. 观察关键信号在仿真波形中我需要重点关注ps2k_clk和ps2k_data的波形是否符合协议。ps2scan模块内部的neg_ps2k_clk下降沿脉冲是否准确。计数器num是否从0递增到10。temp_data寄存器是否在正确的num时刻锁存了正确的数据位。ps2_byte_r是否最终锁存为8‘h1C。ps2_state是否产生了一个单一脉冲。clk_bps脉冲是否在ps2_state后开始规律出现。rs232_tx线上输出的波形是否是一个标准的UART帧先拉低起始位接着是8位数据01000001ASCII ‘A’ 的二进制LSB first最后拉高停止位。3. 仿真发现的问题与解决问题一数据错位。最初发现temp_data接收的数据和发送的不一致。检查发现是边沿检测逻辑写反了neg_ps2k_clk ~ps2k_clk_r1 ps2k_clk_r2这个条件在r1从1变0时并不成立。修正为ps2k_clk_r2 ~ps2k_clk_r1。问题二ps2_state脉冲过宽。在最初的代码里ps2_state_r在一个大的else块里被赋值导致它在一个帧接收周期内可能多次被置位或保持。修正为仅在num4‘d10且是按下事件时产生一个时钟周期的高脉冲其他时候置低。问题三UART发送不完整。仿真发现有时只发了7位数据就停止了。原因是num计数器在clk_bps脉冲下从0走到10但我的case语句只写到了9。补充4‘d10的状态处理逻辑。4.2 板级调试与实物连接仿真通过后就可以进行综合、布局布线并生成比特流文件下载到FPGA开发板了。1. 硬件连接PS/2接口需要一个PS/2母座。连接时注意引脚顺序通常1脚是Data3脚是GND4脚是VCC5V5脚是Clock。务必确认开发板IO电压如果FPGA BANK电压是3.3V直接接5V的PS/2信号有风险。稳妥的做法是使用一个简单的电平转换电路如两个电阻分压或用74LVC4245这类电平转换芯片或者选择支持5V容忍IO的FPGA型号并正确配置。UART接口FPGA的rs232_tx引脚连接到USB转串口模块的RX引脚GND对接。USB转串口模块的另一端插电脑。2. 调试工具串口调试助手电脑端打开串口调试助手如Putty、SecureCRT、或者各种嵌入式IDE自带的工具设置正确的COM口、波特率与代码中一致如9600、数据位8、停止位1、无校验。逻辑分析仪这是硬件调试的利器。我用的是Saleae Logic将探头连接到FPGA的ps2k_clk、ps2k_data、rs232_tx引脚。可以直观地看到协议波形对比仿真结果快速定位是PS/2解码问题还是UART发送问题。3. 常见实物问题与排查现象串口助手无任何显示。检查1电源和连接。确认键盘PS/2口有电键盘指示灯亮吗确认USB转串口模块驱动已安装COM口选择正确。检查2波特率。确认代码中的波特率参数与串口调试助手设置完全一致。9600波特率下BPS_PARA计算错误一位都会导致无法通信。检查3信号捕捉。用逻辑分析仪抓取rs232_tx信号。如果根本没有波形说明UART发送模块没工作回溯bps_start和clk_bps信号。如果有波形但不对看起始位、停止位、数据位是否符合预期。检查4PS/2解码。用逻辑分析仪同时抓ps2k_clk和ps2k_data。按下一个键看是否有标准的11位波形发出。再观察FPGA内部的ps2_byte和ps2_state信号可以通过引出到LED或虚拟IO查看确认解码是否正确。现象按下键显示乱码或错误字符。排查1ASCII映射表。这是最常见的原因。确认你按下的键的扫描码是否与case语句中的值匹配。例如原代码中8‘h1z显然是笔误应该是8‘h1aZ键的扫描码。务必使用标准的Scan Code Set 2表进行核对。排查2UART位序。确认发送模块是LSB first。如果弄反了发送的01000001A会被接收端解读为10000010变成乱码。排查3电平极性。RS-232标准是负逻辑高电平-3V~-15V代表逻辑1低电平3V~15V代表逻辑0。而我们的FPGA输出是正逻辑0V/3.3V。USB转串口芯片如CH340、CP2102、FT232通常自动兼容这两种逻辑。但有些老式串口可能需要MAX232这类芯片进行电平转换。确保你的硬件连接是正确的电平转换方式。现象连续快速按键会丢字。原因分析这是预期内的因为当前设计没有缓冲。UART发送一个字节需要约1ms (1/9600 * 10 bits)而快速打字时按键间隔可能小于1ms。解决方案在ps2scan和my_uart_tx之间加入一个FIFO先入先出缓冲区。当PS/2解码出一个键值就写入FIFOUART发送模块空闲时从FIFO读取数据发送。FIFO的深度可以根据需要设置如16、32这样就能容忍短时间的突发按键。5. 项目优化与扩展思路这个基础版本跑通后你可以从多个方向进行优化和扩展让它变得更实用、更强大。5.1 功能扩展支持小写字母与Shift键PS/2键盘会为修饰键Shift, Ctrl, Alt, Caps Lock也发送独立的通码和断码。你需要增加状态机来跟踪这些修饰键的状态。例如当收到左Shift键的通码0x12时置位一个shift_pressed标志收到其断码0xF0后跟0x12时清除。在扫描码转ASCII时根据shift_pressed或caps_lock标志选择输出大写或小写字母的ASCII码。支持更多按键扩展case语句加入数字键、符号键、功能键F1-F12、方向键、回车、退格等的映射。对于非字符键可以定义一套自己的输出协议比如用特殊的转义序列表示。添加LED状态控制PS/2协议允许主机控制键盘上的Num Lock, Caps Lock, Scroll Lock指示灯。你可以实现向键盘发送命令的功能例如在Caps Lock按下时发送命令点亮对应LED这需要实现PS/2协议的“主机到设备”通信部分稍微复杂一些。实现键盘缓冲区FIFO如上所述用FPGA内部的Block RAM或分布式RAM实现一个同步FIFO解决丢键问题。这是提升产品可用性的关键一步。5.2 性能与可靠性优化添加奇偶校验在ps2scan模块的接收端实现奇偶校验计算。如果校验错误可以丢弃该帧数据或者通过一个错误信号输出增加系统的鲁棒性。更稳健的边沿检测与消抖虽然PS/2时钟信号质量通常较好但为了应对极端情况可以增加更复杂的数字滤波器。例如连续采样多次只有当多次采样值一致时才确认状态变化以滤除毛刺。参数化与可配置性将系统时钟频率、目标波特率、FIFO深度等设计为模块参数(parameter)这样代码只需稍作修改就能适配不同的开发板时钟频率不同或通信需求波特率不同。使用状态机重构当前的ps2scan模块使用计数器num和标志位key_f0本质上是一个状态机但写法比较分散。可以用更清晰的always (posedge clk)块配合state和next_state寄存器使用case语句明确写出“空闲”、“接收数据”、“等待断码”等状态使代码更易读和维护。5.3 系统集成应用这个PS/2解码器可以作为一个IP核集成到更大的系统中嵌入式输入系统与VGA/HDMI显示控制器结合在屏幕上实现一个简单的键盘输入终端。硬件密码锁将键盘输入与密码验证逻辑结合实现一个纯硬件的密码输入装置。游戏控制器将方向键、功能键映射为游戏控制信号。工业HMI作为低成本工控面板的输入设备。调试这个项目的过程让我对硬件时序的理解上了一个台阶。看着逻辑分析仪上规整的协议波形和串口助手里跳出的字符那种“硬件在按我写的代码运行”的成就感是软件编程难以比拟的。希望这份详细的拆解能帮你少走些弯路顺利打通从协议文档到硬件实现的这条路。