3、指令集设计:PLC常用指令(LD、AND、OR、OUT、TIM、CNT)的二进制编码与操作码设计

指令集设计,说白了就是给PLC芯片定一套“暗号”。

CPU不认识“LD X0”这种助记符,它只认0和1。所以,我们必须把每条指令翻译成二进制码。这个翻译过程,就是操作码(Opcode)设计。

我个人习惯把指令集设计比作“字典”。你查字典,先找到拼音(助记符),然后看到解释(操作码)。今天我们就来编这本字典。

3.1 指令格式:定长还是变长?

先定个规矩:一条指令占几个字节?

PLC逻辑扫描引擎追求的是确定性。我建议用定长指令,每条指令固定为4字节(32位)。

为什么?

你想想看,扫描引擎要逐条取指、译码、执行。如果指令长度不固定,取指逻辑会变得复杂,容易产生时序瓶颈。我在项目中遇到过,变长指令虽然节省存储空间,但让流水线设计变得很痛苦。

所以,我们定下来:每条指令32位

位域 宽度 说明
操作码 8位 指令类型,如LD、AND、OR等
操作数类型 4位 指定操作数是X、Y、M、T、C等
操作数地址 16位 具体地址编号,如X0、Y10等
预留/标志 4位 用于扩展或特殊标志

嗯,这里要注意:操作码8位,理论上可以支持256条指令。对于PLC常用指令来说,绰绰有余。

3.2 操作码分配:给每条指令一个“身份证号”

操作码就是指令的ID。我习惯用十六进制表示,清晰明了。

助记符 操作码(十六进制) 功能描述
LD 0x01 取常开触点,将触点状态载入累加器
LDI 0x02 取常闭触点,取反后载入
AND 0x03 与运算,串联常开触点
ANI 0x04 与非运算,串联常闭触点
OR 0x05 或运算,并联常开触点
ORI 0x06 或非运算,并联常闭触点
OUT 0x07 输出结果到指定线圈
TIM 0x08 定时器指令,设定延时时间
CNT 0x09 计数器指令,设定计数值
END 0xFF 程序结束,扫描周期复位

核心原则:操作码分配要留有余地。0x0A到0xFE都是空闲的,方便以后扩展。我曾经见过一个项目,操作码用得太满,后来想加个“上升沿检测”指令,只能硬挤,搞得兼容性很差。

3.3 指令编码示例:从助记符到二进制

光说理论不过瘾,我们直接看例子。

例1:LD X0

  • 操作码:0x01
  • 操作数类型:X寄存器,编码为0x1
  • 操作数地址:X0,地址为0x0000
  • 完整32位:0x01100000(二进制:0000 0001 0001 0000 0000 0000 0000 0000)

例2:OUT Y10

  • 操作码:0x07
  • 操作数类型:Y寄存器,编码为0x2
  • 操作数地址:Y10,地址为0x000A
  • 完整32位:0x0720000A

例3:TIM T0 K100

  • 操作码:0x08
  • 操作数类型:T寄存器,编码为0x4
  • 操作数地址:T0,地址为0x0000
  • 定时值:K100,编码为0x0064(100的十六进制)
  • 完整32位:0x08400064

小技巧:对于TIM和CNT这类需要立即数的指令,我把操作数地址的低16位用来存放定时值/计数值。这样不用额外增加指令长度。嗯,这是空间换时间的做法。

3.4 译码逻辑:CPU怎么读懂这些二进制?

指令编码好了,CPU怎么执行?

说白了,就是查表。CPU内部有一个译码器,它根据操作码,跳转到对应的处理逻辑。

我习惯用C语言风格描述译码逻辑,方便大家理解:

// 伪代码:指令译码执行
void execute_instruction(uint32_t instr) {
    uint8_t opcode = (instr >> 24) & 0xFF;
    uint8_t operand_type = (instr >> 20) & 0x0F;
    uint16_t operand_addr = instr & 0xFFFF;
    
    switch(opcode) {
        case 0x01: // LD
            acc = read_operand(operand_type, operand_addr);
            break;
        case 0x03: // AND
            acc = acc & read_operand(operand_type, operand_addr);
            break;
        case 0x05: // OR
            acc = acc | read_operand(operand_type, operand_addr);
            break;
        case 0x07: // OUT
            write_operand(operand_type, operand_addr, acc);
            break;
        case 0x08: // TIM
            start_timer(operand_addr, operand_addr); // 地址域存定时值
            break;
        case 0x09: // CNT
            start_counter(operand_addr, operand_addr);
            break;
        case 0xFF: // END
            reset_scan();
            break;
        default:
            // 非法指令,触发错误
            error_flag = 1;
            break;
    }
}

你看,译码逻辑其实不复杂。关键是把操作码和功能一一对应好。

避坑指南:我曾经犯过一个错误——把操作码0x00也分配出去了。后来发现,0x00在硬件复位后是默认值,导致未初始化的指令被误执行。所以,操作码0x00一定要保留为NOP(空操作)或非法指令

3.5 指令集设计的核心流程图

下面这张图,概括了从助记符到二进制编码,再到CPU译码执行的完整流程。

指令集设计核心流程 助记符(如 LD X0) 操作码分配(0x01) 二进制编码(32位) CPU译码执行 结果写入寄存器/存储器

3.6 指令集设计的几个关键点

最后,我总结几个实战中容易踩的坑:

  1. 操作码不要从0x00开始——原因前面说了,硬件复位默认值问题。
  2. 同类指令的操作码尽量连续——比如LD、LDI用0x01、0x02,AND、ANI用0x03、0x04。这样译码时可以用位运算快速判断指令类别。
  3. 预留扩展空间——PLC指令集不是一成不变的。我建议至少留50%的操作码空间给未来扩展。
  4. 操作数类型编码要统一——X、Y、M、T、C的编码一旦定下来,就不要改了。改一次,所有固件和工具链都要跟着改,很痛苦。

一句话总结:指令集设计是PLC芯片的“宪法”。定好了,后面所有工作都顺;定不好,后面全是坑。我建议你花时间把操作码表反复推敲几遍,再动手写代码。

好了,这一章的内容就到这里。指令编码搞定了,下一步就是把这些指令串起来,形成真正的扫描执行逻辑。


公众号:蓝海资料掘金营,微信deep3321