拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

ABB机器人RAPID数据类型实战指南:bool/num/string/dnum选型与避坑

ABB机器人RAPID数据类型实战指南:bool/num/string/dnum选型与避坑

1. ABB机器人数据类型:不是语法课,是现场调试的“通用语言”

刚接手ABB机器人项目时,我被现场工程师一句话问懵了:“你连num和dnum都分不清,怎么调轨迹?”——那会儿我还在用PLC的思维看机器人变量,以为所有数字都一样。后来在汽车焊装线连续跟了三周夜班,亲眼看着一个bool信号接错导致整条产线停机47分钟,才真正明白:ABB机器人里的数据类型,根本不是编程语法里的概念,而是设备间握手、逻辑判断、安全联锁的物理契约。它直接决定IO信号能不能通、运动指令会不会报错、HMI显示对不对、甚至急停回路是否有效。今天说的不是教科书定义,而是我在现场拆过200+个RAPID程序、调过87台IRC5控制器、修过34次“变量未初始化”报警后,总结出的几类最常碰、最容易踩坑、但文档里一笔带过的数据类型。核心就四个:bool、num、string、dnum。别小看这四个词,它们背后对应着内存地址分配方式、运算精度边界、通信协议映射规则、甚至示教器显示逻辑。比如num看似是普通数字,但它在RAPID里默认是双精度浮点,而dnum却是64位整数——这个差别在做高精度轨迹插补时,会导致微米级的位置偏差;string在示教器上最多显示255字符,但实际存储空间是256字节(含结尾\0),如果你用StrLen()函数去算长度,结果永远比肉眼看到的少1。这些细节,不写进代码里,但会真实出现在报警日志、通讯超时、坐标偏移里。适合谁看?刚从PLC转过来的自动化工程师、第一次接触IRC5的应届生、需要快速读懂现有RAPID程序的维护人员,以及所有想把机器人程序从“能跑”升级到“稳跑”的人。下面我们就按现场调试的优先级,一层层剥开这四类数据的真实面目。

2. 核心数据类型深度拆解:为什么选这四种?它们解决什么物理问题?

2.1bool:不只是true/false,它是安全回路的“神经末梢”

bool在RAPID里看起来最简单:TRUE或FALSE。但它的底层实现,直接挂钩到IRC5控制器的硬件安全电路。这不是软件模拟的布尔值,而是通过专用安全IO模块(如DSQC652)的物理触点状态映射而来。当你在RAPID里声明VAR bool bDoorOpen := FALSE;,这个变量在内存中占用1个字节,但它的读写操作会触发控制器内部的安全状态机校验。举个真实案例:某电池模组装配线,安全门开关信号接入DSQC652的X1端口,RAPID程序里用bDoorOpen := DI_1;读取。表面看是赋值,实则控制器每20ms执行一次安全周期扫描——先检查DI_1电平是否稳定超过3个扫描周期,再确认该信号未与急停信号发生逻辑冲突,最后才更新bDoorOpen的值。如果跳过这个流程直接用bDoorOpen := TRUE;硬编码,安全功能将被旁路,整条线失去CE认证合规性。这也是为什么ABB官方严禁在安全逻辑中使用PERS(永久变量)修饰bool型安全信号:断电重启后,PERS变量会保持上次值,而物理开关状态可能已变,造成“假安全”。实操中,bool必须配合IF bDoorOpen THEN ... ELSE ... ENDIF结构使用,且分支内不能嵌套复杂计算——安全逻辑扫描周期要求≤20ms,任何浮点运算都会拖慢响应。我见过最典型的错误,是把bool和num混用:IF nSpeed > 0 THEN bMotorRun := TRUE; ENDIF,这里nSpeed是num,但bMotorRun是bool,看似合理,实则当nSpeed因传感器噪声出现0.0001的抖动时,bMotorRun会在TRUE/FALSE间高频翻转,烧毁接触器线圈。正确做法是加死区:IF nSpeed > 0.1 THEN bMotorRun := TRUE; ELSE bMotorRun := FALSE; ENDIF。记住:bool不是逻辑开关,是安全契约的具象化。

2.2num:双精度浮点的“温柔陷阱”,精度与性能的平衡术

num是RAPID里最常用也最危险的数据类型。它本质是IEEE 754双精度浮点(64位),可表示±1.7976931348623157E+308范围内的数,精度约15-17位十进制数字。但问题在于:机器人运动控制对数值稳定性要求极高。比如设置TCP(工具中心点)偏移量p[0,0,150,0,0,0],这里的150单位是毫米,若用num存储,150.00000000000000000001和149.99999999999999999999在数学上几乎相等,但在关节伺服驱动器解析时,可能触发不同的插补算法分支,导致轨迹微抖。更隐蔽的是num的“隐式类型转换”。RAPID允许num与整数直接运算,如nPos := 100 + 0.5;,但编译器会把100自动转为num再计算。问题出在常量字面量:100是整数,100.0才是num。当大量使用100参与运算时,编译器需反复进行类型提升,增加CPU负载。我们曾遇到一台IRB6640在执行复杂路径时,CPU占用率突然飙升至95%,排查发现是200多处num变量初始化用了0而非0.0,导致每次循环都触发隐式转换。解决方案很简单:所有num变量初始化必须显式写.0,如VAR num nOffset := 150.0;。另一个致命坑是num的比较。IF nCurrentPos = nTargetPos THEN这种写法在RAPID里永远不安全,因为浮点数存在舍入误差。正确姿势是引入容差:IF Abs(nCurrentPos - nTargetPos) < 0.01 THEN。这个0.01不是随便写的——它必须小于机器人重复定位精度(IRB6640标称±0.05mm),否则会漏判到位。我习惯把容差定义为常量:CONST num nTolerance := 0.01;,既提高可读性,又方便后期根据机械磨损调整。最后提醒:num不能用于计数器。曾有客户用nCounter := nCounter + 1.0;做循环计数,运行10万次后nCounter变成100000.0000000001,导致FOR循环提前退出。计数器必须用dnum。

2.3string:255字符的“信息茧房”,显示、存储与传输的三重约束

string在RAPID里声明为VAR string sMsg := "Hello";,但它的行为远比字符串复杂。首先明确:RAPID的string是定长数组,最大长度255字符(实际分配256字节内存,含终止符\0)。这意味着sMsg := "A" + "B" + "C"这样的拼接,如果原字符串已满255字,新内容会被截断。更关键的是示教器显示逻辑:示教器屏幕每行最多显示32个ASCII字符,但string变量在“程序数据”界面里,只显示前40个字符,后面用...省略。这导致调试时极易误判——你以为变量存了完整路径"C:\ProgramData\Robot\Logs\Error_20240520.log",实际只存了"C:\ProgramData\Robot\Logs\Error_20240520..."。验证方法只有用StrLen(sMsg)函数查真实长度,或用StrPart(sMsg, 1, StrLen(sMsg))提取全部内容。其次是编码问题:RAPID默认使用ISO-8859-1(Latin-1)编码,不支持UTF-8中文。如果你在PC端用记事本保存含中文的.txt文件,再通过RobotStudio上传到控制器,中文会变成乱码中国。解决方案是改用Notepad++,编码选“ANSI”(即Windows-1252),再保存上传。第三是通信限制:通过Socket发送string时,必须确保接收端缓冲区足够大。我们曾用SendStr(sock, sLogData);发日志,但接收端缓冲区设为256字节,而sLogData实际长度257,导致最后一个字符丢失,解析JSON时}缺失,整个日志失效。经验是:string传输前,先用StrLen()检查长度,超限时主动截断或分包。最后强调:string不能参与数学运算。sNum := "123" + "456"结果是"123456"(拼接),但sNum := "123" * 2会直接报错ERR_NUMERICAL。若需数值计算,必须用Val()函数转换:nResult := Val("123") * 2;。注意Val()遇到非数字字符会返回0,所以Val("12a3")得0,不是123。

2.4dnum:64位整数的“精密齿轮”,专治计数与大数运算

dnum是RAPID里最被低估的数据类型。它代表64位有符号整数,范围-9,223,372,036,854,775,808到9,223,372,036,854,775,807。名字里的d指“double word”,强调其字长。它的核心价值在三个场景:精确计数、大数索引、无损运算。先说计数:VAR dnum dCounter := 0;,每次dCounter := dCounter + 1;都是原子操作,无浮点误差。我们调试激光切割机时,用dCounter记录已切割工件数,连续运行3个月零故障,而之前用num的版本,每10万次计数就出现+1/-1偏差。再看索引:RAPID数组最大维度是ARRAY[1..10000] OF num,但若要定义ARRAY[1..50000] OF num,编译器会报错“array too large”。此时必须用dnum做索引变量:FOR dIdx FROM 1 TO 50000 DO arrData{dIdx} := ... ENDROF,因为dnum支持更大范围的循环变量。最关键的是无损运算:num做乘除会有精度损失,而dnum全程整数运算。例如计算脉冲数:电机每转10000脉冲,要走1500mm,丝杠导程10mm/转,则总脉冲=(1500 / 10) * 10000。用num算:nPulse := (1500.0 / 10.0) * 10000.0;,结果可能是1500000.0000000002;用dnum:dPulse := (1500 DIV 10) * 10000;,结果严格等于1500000。注意DIV是整除运算符,/才是浮点除。dnum的坑在于:它不能直接赋值给num变量。nValue := dCounter;会报错,必须显式转换:nValue := ConvNum(dCounter);。这个转换函数内部做类型检查,若dCounter超出num范围(±1.79E308),会触发ERR_OVERFLOW。所以大数运算后,务必用IF dResult > 9007199254740992 THEN ...做溢出防护——这个阈值是num能精确表示的最大整数(2^53)。

3. 实操场景还原:从示教器配置到RAPID代码落地

3.1 场景一:安全门状态监控(bool实战)

这是最基础也最易出错的场景。假设产线有3个安全门,分别接入DSQC652的DI_1、DI_2、DI_3端口,要求任一开门时停止所有轴运动,并在HMI显示具体门号。

第一步:硬件配置确认
在RobotStudio的“Control Panel”→“I/O System”里,确认DI_1~DI_3的信号类型为Digital Input,安全等级设为Category 3(符合ISO 13849-1)。右键信号→“Properties”,勾选Safe Input。这步不能跳过,否则bool变量无法触发安全停机。

第二步:RAPID变量声明

! 安全信号必须声明为PERS(永久),但仅限非安全逻辑部分 PERS bool gbDoor1Open := FALSE; PERS bool gbDoor2Open := FALSE; PERS bool gbDoor3Open := FALSE; ! 运行状态标志,用VAR(临时) VAR bool bAllDoorsClosed := TRUE;

注意:gbDoor*Open用PERS是为了断电重启后保持上次状态供诊断,但安全判断逻辑必须实时读取物理输入。

第三步:主循环安全检查

PROC main() WHILE TRUE DO ! 实时读取物理输入(非PERS变量) gbDoor1Open := DI_1; gbDoor2Open := DI_2; gbDoor3Open := DI_3; ! 组合判断:任一为TRUE则bAllDoorsClosed为FALSE bAllDoorsClosed := NOT (gbDoor1Open OR gbDoor2Open OR gbDoor3Open); ! 安全停机逻辑(必须放在循环开头) IF NOT bAllDoorsClosed THEN StopMove; ! 立即停止运动 ! 发送HMI消息(需预定义MessageID) WriteText \ID:=1001 \Str:="SAFETY STOP: DOOR OPEN"; ! 等待复位(此处简化,实际需配合安全继电器) WHILE NOT (DI_4 AND DI_5) DO ! 复位按钮+确认信号 WaitTime 0.1; ENDWHILE ENDIF ! 其他正常逻辑... MoveJ pHome, v1000, z50, tool0; WaitTime 1; ENDWHILE ENDPROC

关键点:StopMove必须在IF分支内立即执行,不能有任何延迟;WriteText的\ID必须在HMI工程里预先配置,否则消息不显示;复位逻辑中DI_4 AND DI_5是双通道确认,防止单点故障误复位。

第四步:调试验证
在示教器“Program Editor”里,打开“View”→“Program Data”,找到gbDoor1Open变量,手动修改为TRUE,观察机器人是否立即停机并弹出消息。再用万用表测DI_1端口电压,确认TRUE对应24V,FALSE对应0V。若停机延迟>20ms,检查安全IO模块固件版本,旧版可能需升级。

3.2 场景二:TCP偏移量动态校准(num与dnum协同)

某视觉引导装配任务,需根据相机反馈实时调整TCP位置。相机给出X/Y/Z偏移量(单位mm),精度要求±0.02mm。

第一步:数据接收与存储
相机通过Ethernet/IP发送数据,RAPID用Socket接收。假设收到字符串"X:12.345,Y:-6.789,Z:0.123"。

! 声明接收缓冲区(string最大255) VAR string sRecvBuf := ""; ! 接收并解析 RecvStr(sock, sRecvBuf, 255); ! 提取X值:找"X:"后第一个逗号前的内容 VAR string sXStr := StrPart(sRecvBuf, Pos("X:", sRecvBuf) + 2, Pos(",", sRecvBuf) - Pos("X:", sRecvBuf) - 2); ! 转换为num(注意:Val()返回num) VAR num nXOffset := Val(sXStr); ! 但X偏移量需累加,用dnum防误差 PERS dnum dXAccum := 0; dXAccum := dXAccum + ConvDnum(Round(nXOffset * 1000)); ! 转为微米,用dnum累加 ! 再转回num用于运动指令(Round避免浮点误差) VAR num nXFinal := ConvNum(dXAccum) / 1000.0;

这里ConvDnum(Round(...))是关键:Round()函数将num四舍五入到整数,ConvDnum()转为dnum,ConvNum()再转回num。整个过程消除浮点累加误差。

第二步:TCP更新

! 构建新TCP(p[x,y,z,rx,ry,rz]) VAR pose pNewTCP := pTool0; ! pTool0是原始TCP pNewTCP.trans.x := pTool0.trans.x + nXFinal; pNewTCP.trans.y := pTool0.trans.y + nYFinal; ! 同理处理Y/Z pNewTCP.trans.z := pTool0.trans.z + nZFinal; ! 应用新TCP(需在运动前设置) SetToolData \Tool:=tool0 \WObj:=wobj0 \Pose:=pNewTCP;

注意:SetToolData必须在MoveL等运动指令前执行,且不能在运动中调用,否则报错ERR_TOOL_DATA。

第三步:精度验证
用激光跟踪仪测量实际TCP位置,对比nXFinal理论值。若偏差>0.02mm,检查相机标定参数是否更新,或Round()函数是否被编译器优化。RAPID的Round()在某些固件版本有舍入偏差,可改用Trunc(nXOffset * 1000 + 0.5)替代。

3.3 场景三:生产批次号生成与日志记录(string深度应用)

产线需为每个工件生成唯一批次号,格式"20240520-00001",并写入本地日志文件。

第一步:批次号生成

! 获取当前日期(系统函数) VAR date dtNow := Date(); ! 格式化为YYYYMMDD VAR string sDate := NumToStr(dtNow.year, 4) + NumToStr(dtNow.month, 2) + NumToStr(dtNow.day, 2); ! 计数器用dnum保证不丢 PERS dnum dBatchSeq := 0; dBatchSeq := dBatchSeq + 1; ! 拼接批次号(注意:NumToStr第二个参数是位数,不足补0) VAR string sBatchNo := sDate + "-" + NumToStr(dBatchSeq, 5); ! 验证长度(确保不超过255) IF StrLen(sBatchNo) > 255 THEN WriteText \ID:=1002 \Str:="BATCH NO TOO LONG!"; Stop; ENDIF

NumToStr(dBatchSeq, 5)生成"00001",比手动拼"000" + ...更可靠。

第二步:日志写入

! 打开文件(需提前在控制器创建目录) VAR string sLogPath := "C:/Users/Default User/Logs/"; VAR string sFileName := sLogPath + "batch_" + sDate + ".log"; ! 写入一行(追加模式) VAR string sLogLine := sBatchNo + "," + TimeStr() + "," + "OK" + "\n"; WriteFile sFileName, sLogLine, "APPEND";

关键点:WriteFile的第三个参数"APPEND"确保不覆盖,但需确认控制器SD卡有足够空间。我们曾因日志文件单个超2GB导致写入失败,解决方案是每天0点新建文件,并用FileExist()检查文件大小,超100MB则切换。

第三步:HMI同步显示
通过WriteText发送批次号到HMI,但HMI只能显示前32字符。因此额外发送序列号:

! 发送精简版到HMI(取后10位) VAR string sHMIshow := StrPart(sBatchNo, StrLen(sBatchNo) - 9, 10); WriteText \ID:=1003 \Str:=sHMIshow;

4. 常见问题与排查技巧实录:那些让老手也挠头的“幽灵错误”

4.1 问题速查表:高频报错与根因定位

报错代码报错信息最可能根因排查步骤解决方案
ERR_UNINITIALIZEDVariable not initializednum/string变量声明后未赋初值在“Program Data”界面查看变量值是否为<UNINIT>所有VAR变量声明时必须初始化,如VAR num nPos := 0.0;
ERR_NUMERICALNumerical error in expressionnum除零、string非法转换检查报错行附近是否有/0、Val("abc")用IF nDenom <> 0 THEN ... ENDIF包裹除法;Val()前用StrFind()验证格式
ERR_ARRAY_BOUNDSArray index out of boundsdnum索引超数组维度查看数组声明ARRAY[1..100],确认循环变量dIdx <= 100循环前加IF dIdx > 100 THEN dIdx := 100 ENDIF
ERR_STRING_LENGTHString length exceededstring拼接超255字符用StrLen()检查拼接前长度拼接前截断:sA := StrPart(sA, 1, 200); sB := StrPart(sB, 1, 50);
ERR_TYPE_MISMATCHType mismatch in assignmentnum与dnum直接赋值查看赋值语句左右两边数据类型使用ConvNum()或ConvDnum()显式转换

4.2 独家避坑技巧:现场调试的“肌肉记忆”

技巧一:bool信号真假难辨?用示教器“Signal Monitor”抓波形
当安全门信号疑似抖动,不要只看变量值。在示教器“Control Panel”→“I/O System”→“Signal Monitor”,添加DI_1信号,设置采样率1kHz,运行10秒。若波形出现毛刺(宽度<10ms),说明物理信号不稳定,需检查接线屏蔽或加RC滤波电路。单纯在RAPID里加延时滤波(WaitTime 0.05)治标不治本。

技巧二:num精度怀疑?用NumToStr()输出17位小数
NumToStr(nValue, 17)可显示num的完整精度。例如nTest := 0.1 + 0.2;,输出"0.30000000000000004",立刻暴露浮点误差。这比Abs(nTest - 0.3) < 0.001更直观。

技巧三:string乱码溯源?查RobotStudio的“Encoding”设置
在RobotStudio导入RAPID程序时,右下角状态栏会显示文件编码。若为UTF-8,点击它选择ANSI再重新加载。这是解决中文乱码最快的方法,比改Notepad++编码更直接。

技巧四:dnum溢出无声?加编译期断言
RAPID不支持assert,但可用IF模拟:

dLarge := 1000000000000; IF dLarge > 9007199254740992 THEN ! 此处放调试信息 WriteText \ID:=1004 \Str:="DNUM MAY LOSE PRECISION!"; ENDIF

把这行放在所有大数运算后,形成条件断点。

技巧五:变量莫名改变?检查“Task”优先级
多个RAPID任务(如T_ROB1、T_ROB2)共享同一PERS变量时,若任务优先级相同,会出现竞态。解决方案:在“Task Configuration”里,将主逻辑任务设为Priority 10,监控任务设为Priority 5,并用Sync指令同步访问。

4.3 真实故障复盘:一个string截断引发的全线停产

去年某食品包装线,机器人突然在第12784个工件时报错ERR_STRING_LENGTH,停机。日志显示"Batch_20240515-12784,2024-05-15 14:23:01,OK",长度明明42字符。我们逐行检查,发现WriteFile前有一行:

sLogLine := sBatchNo + "," + TimeStr() + "," + sStatus + "\n" + sExtraInfo;

而sExtraInfo是从视觉系统读取的缺陷描述,最长可达200字符。问题在于:sExtraInfo有时为空(""),但StrLen("")返回0,sLogLine总长=42+0+1=43,安全。但某次视觉系统异常,返回了"Defect: Hole at position X=123.456,Y=78.901"(含小数点),StrLen()返回45,总长42+45+1=88,仍安全。直到某天视觉返回"Defect: Hole at position X=123.4567890123456789,Y=78.90123456789012345"——StrLen()=82,总长42+82+1=125,还是安全。最终发现是TimeStr()函数:标准返回"14:23:01"(8字符),但系统时间同步时偶尔返回"14:23:01.123"(12字符),加上sExtraInfo的82,总长42+12+1+82=137,依然安全。真相在WriteFile的底层:它实际调用Windows API,而IRC5的Windows子系统对单行日志有隐式限制——137字符没问题,但当sExtraInfo包含特殊字符如"©"(Unicode)时,StrLen()仍返回82,但实际字节数超256,触发底层缓冲区溢出。解决方案:所有string拼接前,用StrLen()检查总长,并强制截断:

sLogLine := sBatchNo + "," + TimeStr() + "," + sStatus + "\n"; IF StrLen(sLogLine) + StrLen(sExtraInfo) <= 250 THEN sLogLine := sLogLine + sExtraInfo; ELSE sLogLine := sLogLine + StrPart(sExtraInfo, 1, 250 - StrLen(sLogLine)); ENDIF

这个250留了5字节余量,防万一。

5. 数据类型选型决策树:面对需求,如何三秒锁定最优类型

5.1 决策逻辑:从物理需求反推数据类型

很多工程师习惯先想“我要存什么”,再选类型。正确顺序是:先问设备物理特性,再定数据类型。比如:

  • 问IO信号来源:如果是光电开关、按钮这类通断信号,必选bool;如果是编码器脉冲计数,必选dnum;如果是模拟量传感器(4-20mA),必选num(因ADC转换 inherently 有浮点误差)。

  • 问精度要求:定位精度±0.01mm?用num,容差设0.01;计数精度要求100%?用dnum;显示文本长度确定?用string,但预留20%余量。

  • 问通信协议:Modbus TCP寄存器是16位整数,读取后必须用ConvDnum()转dnum再运算;OPC UA节点若定义为Double,则用num;若定义为Int64,则用dnum。

  • 问生命周期:需要断电保持?用PERS修饰,但PERS bool仅限非安全逻辑;需要高速循环?避免string拼接,改用num数组存ASCII码。

5.2 场景化选型表:照着填空就能用

应用场景物理需求推荐类型关键参数/注意事项替代方案风险
急停按钮状态硬件安全回路,需CE认证bool必须接DSQC652安全IO,禁用PERS修饰用num:失去安全认证,法律风险
伺服电机位置反馈编码器脉冲数,0-1000000dnum初始化dPos := 0,用+/-运算用num:10万次后累计误差>1脉冲
温度传感器读数PT100电阻值,精度±0.1℃num初始化nTemp := 25.0,容差0.1用dnum:无法表示小数,精度归零
HMI显示产品型号字符串,最大30字符string声明VAR string sModel := "ABC-123";,长度≤30用num数组:开发效率低,易出错
生产订单号10位数字,需排序dnum用NumToStr(dOrderID, 10)转显示用string:字符串排序"2">"10",逻辑错误

5.3 终极检验:三句话自测选型是否正确

  1. 内存检验:这个变量在控制器里占多少字节?bool=1字节,num=8字节,string=256字节,dnum=8字节。若一个string数组存1000个工件名,内存占用256KB,是否超出IRC5的RAM余量?(IRC5标配2GB RAM,但系统占用1.2GB,余量仅800MB)

  2. 精度检验:这个值参与的最严苛运算,结果误差是否在物理允许范围内?例如num做Sin()运算,结果误差0.0001弧度,在1m臂长上导致0.1mm偏差,是否可接受?

  3. 安全检验:如果这个变量被恶意篡改(如网络攻击),最坏情况是什么?bool被置TRUE导致停机,是安全的;num被置极大值导致超速,是危险的——此时必须加范围校验:IF nSpeed > 2000 THEN nSpeed := 2000 ENDIF。

我在现场的习惯是:写完变量声明,立刻在旁边注释这三句话的答案。比如:

! VAR num nSpeed := 0.0; // 1. 占8字节;2. 误差<0.01mm可接受;3. 超速风险,已加IF校验

这比写// 速度设定值有用得多。

最后分享个小技巧:RAPID编辑器里,把光标停在变量名上,按F1会弹出该类型的官方说明——但里面没写“为什么”。真正的“为什么”,藏在现场每一次停机、每一行日志、每一次示教器闪烁里。把这些碎片拼起来,你就不再是在写代码,而是在和机器对话。

返回列表