ESP-IDF 中使用 C 宏编程 ULP FSM 协处理器(Legacy 方式)完全指南 📅 发布时间:2026/9/17 4:01:26 👁 浏览次数: ESP-IDF 中使用 C 宏编程 ULP FSM 协处理器Legacy 方式完全指南【免费下载链接】esp-idfEspressif IoT Development Framework. Official development framework for Espressif SoCs.项目地址: https://gitcode.com/GitHub_Trending/es/esp-idf本文基于 ESP-IDF 官方文档 docs/en/api-reference/system/ulp_macros.rst系统讲解如何在主应用中以 C 语言宏I_XXX/M_XXX直接编写 ULP FSM有限状态机协处理器程序并通过ulp_process_macros_and_load与ulp_run完成装载与启动。读者学完后将能够在 ESP32 / ESP32-S2 / ESP32-S3 上脱离汇编工具链用纯 C 数组的形式编写、调试并运行低功耗 ULP 程序掌握全部指令宏的参数语义、分支标签宏的解析原理及常见错误处理。ULP FSM 与宏编程方式概述ULPUltra Low Power协处理器是一个简单的有限状态机设计用于在主处理器处于 Deep-sleep 模式时执行 ADC 采样、温度传感器测量、I2C 外设读取等任务。ULP 协处理器可以访问RTC_SLOW_MEM内存区域以及RTC_CNTL、RTC_IO、SARADC外设的寄存器使用定宽 32 位指令、32 位内存寻址并拥有 4 个 16 位通用寄存器R0—R3。该协处理器在 ESP-IDF 中被称为ULP FSM。除了为 ULP 协处理器提供的 binutils 汇编工具链之外ESP-IDF 还提供了一种不需要单独编译汇编文件的编程方式将汇编风格的指令以 C 预处理宏的形式嵌入到 ESP-IDF 主应用中。每个I_XXX宏展开后对应一条 32 位指令最终的程序就是一个ulp_insn_t类型即union ulp_insn的数组。该方式是 ULP FSM 编程的Legacy传统方式。相关头文件位于 components/ulp/ulp_fsm/include/esp32/ulp.hESP32-S2/S3 分别对应esp32s2/ulp.h、esp32s3/ulp.h类型定义与加载函数声明位于 components/ulp/ulp_fsm/include/ulp_fsm_common.h。第一个程序宏数组的定义、装载与运行以下示例完整展示了宏编程的核心流程定义指令数组 → 计算数组大小 → 宏解析并装载 → 启动运行const ulp_insn_t program[] { I_MOVI(R3, 16), // R3 - 16 I_LD(R0, R3, 0), // R0 - RTC_SLOW_MEM[R3 0] I_LD(R1, R3, 1), // R1 - RTC_SLOW_MEM[R3 1] I_ADDR(R2, R0, R1), // R2 - R0 R1 I_ST(R2, R3, 2), // R2 - RTC_SLOW_MEM[R3 2] I_HALT() }; size_t load_addr 0; size_t size sizeof(program)/sizeof(ulp_insn_t); ulp_process_macros_and_load(load_addr, program, size); ulp_run(load_addr);该程序执行的操作是从RTC_SLOW_MEM[16]和RTC_SLOW_MEM[17]分别读出两个数相加结果写回RTC_SLOW_MEM[18]然后停机。这正是 components/ulp/test_apps/ulp_fsm/main/test_ulp.c 中 ULP FSM addition test 测试用例的完整复刻该测试预先写入RTC_SLOW_MEM[16] 10; RTC_SLOW_MEM[17] 11;随后断言RTC_SLOW_MEM[18] 0xffff 21。两个必须注意的细节数组必须定义在局部作用域由于部分指令宏会展开为内联函数调用例如I_BL中使用了abs()在全局作用域定义该数组会导致编译器报错initializer element is not constant。解决方法是把指令数组的定义移动到函数内部局部作用域。地址以 32 位字为单位I_LD、I_ST、I_MOVI等加载、存储、移动指令中使用的地址均以32 位字为单位表达地址 0 对应RTC_SLOW_MEM的第一个字。这一点与汇编代码中同一条指令对地址参数的处理不同汇编按字节偏移计算具体差异可参考 docs/en/api-reference/system/ulp-fsm.rst 中关于寻址的说明。宏参数的基本规则指令宏的参数可以是寄存器编号R0—R3定义于 components/ulp/ulp_fsm/include/esp32/ulp.h和字面常量。寄存器宏R0/R1/R2/R3分别取值 0/1/2/3直接对应于 4 个 16 位通用寄存器。I_XXX 指令宏速查根据 components/ulp/ulp_fsm/include/esp32/ulp.h 中的定义I_XXX指令宏可分为以下几类内存访问类宏语义说明I_LD(reg_dest, reg_addr, offset_)reg_dest - RTC_SLOW_MEM[reg_addr offset_]从 RTC 内存字中加载低 16 位offset_以字为单位I_ST(reg_val, reg_addr, offset_)RTC_SLOW_MEM[reg_addr offset_] - reg_val写入的 32 位布局bits[31:21]为当前指令 PC以字计、bits[20:18]为 3b0、bits[17:16]为地址寄存器号、bits[15:0]为要存储的寄存器值由于I_ST会把指令 PC 与地址寄存器号打包进高 16 位读取时通常用 0xffff提取低 16 位有效数据测试用例中即如此断言。算术逻辑类ALU寄存器-寄存器运算SUB_OPCODE_ALU_REGI_ADDR(dest, src1, src2)加法dest src1 src2I_SUBR(dest, src1, src2)减法dest src1 - src2I_ANDR(dest, src1, src2)按位与dest src1 src2I_ORR(dest, src1, src2)按位或dest src1 | src2I_MOVR(dest, src)寄存器拷贝dest srcI_LSHR(dest, src, shift)逻辑左移dest src shiftI_RSHR(dest, src, shift)逻辑右移dest src shift寄存器-立即数运算SUB_OPCODE_ALU_IMM立即数为 16 位I_ADDI(dest, src, imm)dest src immI_SUBI(dest, src, imm)dest src - immI_ANDI(dest, src, imm)dest src immI_ORI(dest, src, imm)dest src | immI_MOVI(dest, imm)立即数装载dest immI_LSHI(dest, src, imm)按立即数左移I_RSHI(dest, src, imm)按立即数右移阶段计数器运算SUB_OPCODE_ALU_CNTULP 内置一个 8 位阶段计数器I_STAGE_INC(imm)阶段计数器加immI_STAGE_DEC(imm)阶段计数器减immI_STAGE_RST()阶段计数器清零分支类直接使用相对偏移直接使用相对 PC 偏移的分支指令pc_offset以字为单位范围-127 ~ 127I_BL(pc_offset, imm_value)若R0 imm_value则跳转B_CMP_Limm 为 16 位I_BGE(pc_offset, imm_value)若R0 imm_value则跳转B_CMP_GEI_JUMPS(pc_offset, imm_value, comp_type)基于阶段计数器的条件跳转comp_type取JUMPS_LT、JUMPS_GE、JUMPS_LEimm 为 8 位绝对跳转SUB_OPCODE_BX地址以字为单位可来自立即数或寄存器I_BXI(imm_pc)无条件跳转到绝对地址I_BXR(reg_pc)无条件跳转到寄存器中保存的地址I_BXZI(imm_pc)/I_BXZR(reg_pc)若 ALU 上次结果为 0 则跳转BX_JUMP_TYPE_ZEROI_BXFI(imm_pc)/I_BXFR(reg_pc)若 ALU 上次运算溢出则跳转BX_JUMP_TYPE_OVF外设访问与传感器类I_WR_REG(reg, low_bit, high_bit, val)写RTC_CNTL_/RTC_IO_/SENS_/RTC_I2C外设寄存器位域reg[high_bit : low_bit] valI_WR_REG_BIT(reg, shift, val)是单比特便捷版I_RD_REG(reg, low_bit, high_bit)读外设寄存器位域到 R0I_ADC(reg_dest, adc_idx, pad_idx)执行 SAR ADC 测量adc_idx选择 ADC0/ADC1pad_idx选择 ADC pad0—7结果存入reg_destI_TSENS(reg_dest, delay)温度传感器测量delay可取 1 ~ (114)-1值越大分辨率越高精度一般多数场景不推荐I_I2C_READ(slave_sel, sub_addr)/I_I2C_WRITE(slave_sel, sub_addr, val)I2C 读/写字节读结果存入 R07 位从机地址需预先写入SENS_I2C_SLAVE_ADDRx寄存器字段控制流终止类I_HALT()停机。协处理器停止执行但ULP 定时器仍然有效因此程序会再次被定时器重启I_WAKE()发起 SoC 深睡唤醒需先调用esp_deep_sleep_enable_ulp_wakeup程序仍会继续执行到I_HALTI_END()清除RTC_CNTL_STATE0_REG中的RTC_CNTL_ULP_CP_SLP_TIMER_EN位彻底停掉 ULP 定时器程序不会再被自动重启直到再次调用ulp_runI_SLEEP_CYCLE_SEL(timer_idx)选择定时器使用的SENS_SLEEP_CYCLES_Sx寄存器x 0..4默认使用SENS_SLEEP_CYCLES_S0I_DELAY(cycles)延时cycles个时钟周期相当于带参数 NOPM_XXX 分支宏标签与跳转由于手工计算相对偏移极其不便ESP-IDF 提供了M_前缀的宏用于生成带标签的分支指令M_LABEL(label_num)定义一个分支目标标签标签标识符为16 位整数取值范围 0—65535M_BL(label_num, imm_value)若R0 imm_value跳转到标签M_BGE(label_num, imm_value)若R0 imm_value跳转到标签M_BX(label_num)无条件跳转到标签M_BXZ(label_num)若 ALU 结果为 0 跳转到标签M_BXF(label_num)若 ALU 溢出跳转到标签M_BSLT(label_num, imm_value)/M_BSGE(label_num, imm_value)/M_BSLE(label_num, imm_value)基于阶段计数器与立即数比较的跳转M_BSEQ(label_num, imm_value)/M_BSGT(label_num, imm_value)阶段计数器等于/大于比较由两条I_JUMPS组合实现展开为 3 个ulp_insn_t值M_MOVL(reg_dest, label_num)将标签处的程序计数器值移入寄存器之后可与I_BXR、I_BXZR、I_BXFR配合实现动态跳转宏展开的实质token 真实指令每个M_分支宏展开后会生成两个ulp_insn_t值以逗号分隔一个token 值SUB_OPCODE_MACRO_LABEL/SUB_OPCODE_MACRO_BRANCH/SUB_OPCODE_MACRO_LABELPCopcode 为OPCODE_MACRO 15其中携带标签编号一条真实指令如I_BL(0, imm_value)偏移先填 0 占位。ulp_process_macros_and_load函数负责把标签号解析成实际地址 → 改写分支指令的偏移/地址字段 →移除多余的 token 指令ulp_insn_t。因此数组中宏 token 的个数越多最终装入 RTC 内存的真实指令条数越少size输出参数也会相应减小。标签与分支完整示例以下程序反复计算RTC_SLOW_MEM[32] - RTC_SLOW_MEM[33]把结果依次写入RTC_SLOW_MEM[34]开始的位置直到R0达到 64const ulp_insn_t program[] { I_MOVI(R0, 34), // R0 - 34 M_LABEL(1), // label_1 I_MOVI(R1, 32), // R1 - 32 I_LD(R1, R1, 0), // R1 - RTC_SLOW_MEM[32] I_MOVI(R2, 33), // R2 - 33 I_LD(R2, R2, 0), // R2 - RTC_SLOW_MEM[33] I_SUBR(R3, R1, R2), // R3 - R1 - R2 I_ST(R3, R0, 0), // RTC_SLOW_MEM[R0] - R3 I_ADDI(R0, R0, 1), // R0 M_BL(1, 64), // if (R0 64) goto label_1 I_HALT(), }; RTC_SLOW_MEM[32] 42; RTC_SLOW_MEM[33] 18; size_t load_addr 0; size_t size sizeof(program)/sizeof(ulp_insn_t); ulp_process_macros_and_load(load_addr, program, size); ulp_run(load_addr);这段代码与 components/ulp/test_apps/ulp_fsm/main/test_ulp.c 中 ULP FSM subtraction and branch test 测试完全一致。该测试的运行断言为RTC_SLOW_MEM[34]至RTC_SLOW_MEM[63]全部等于42 - 18 24且RTC_SLOW_MEM[64]保持为 0验证了循环分支被正确解析。一个更综合的实战样例是 components/ulp/test_apps/ulp_fsm/main/test_ulp_manual.c 中的 LED 翻转程序它同时使用了M_LABEL、M_BXZ、M_BL、M_BX、I_WR_REG、I_DELAY、I_WAKE、I_END、I_HALT展示了宏编程在控制 RTC GPIO 延时循环 唤醒主 CPU完整场景下的用法。宏解析与装载的底层原理ulp_process_macros_and_load(load_addr, program, psize)的实现位于 components/ulp/ulp_fsm/ulp_macro.c。其内部流程分为四个步骤统计宏数量遍历程序统计 opcode 为OPCODE_MACRO的 token 个数据此分配reloc_info_t重定位数组每个 token 对应一个重定位记录记录结构为{label:16, addr:11, type:4}。移除 token 并记录重定位使用读写双指针遇到宏 token 时把(label, 当前指令地址, 类型)记入重定位表并跳过遇到真实指令则原样写入输出位置。最终所有 token 被剔除真实程序大小*psize随之缩小。排序重定位表按标签号、再按类型label 在前branch 在后排序便于后续查找。修复指令对每条重定位记录根据类型改写目标指令RELOC_TYPE_BRANCH对SUB_OPCODE_B/SUB_OPCODE_BS计算label_addr - insn_addr得到有符号偏移拆分为 7 位偏移绝对值与 1 位符号位写回若绝对值超过 127 则报ESP_ERR_ULP_BRANCH_OUT_OF_RANGE。对SUB_OPCODE_BX则直接把标签地址写入bx.addr字段。RELOC_TYPE_LABELPC对应M_MOVL把标签地址写入配套I_MOVI指令的imm字段该宏展开时先以I_MOVI(reg, 0)占位。内存边界与错误码装载过程中还会做两类边界检查基于CONFIG_ULP_COPROC_RESERVE_MEM配置项即 menuconfig 中为协处理器预留的 RTC 慢速内存字数load_addr超出预留区 → 返回ESP_ERR_ULP_INVALID_LOAD_ADDR解析后的真实程序大小 装载地址超出预留区 → 返回ESP_ERR_ULP_SIZE_TOO_BIG标签号重复定义 →ESP_ERR_ULP_DUPLICATE_LABEL分支引用了不存在的标签 →ESP_ERR_ULP_UNDEFINED_LABEL堆分配失败 →ESP_ERR_NO_MEM。全部 ULP 相关错误码统一定义于 components/ulp/ulp_fsm/include/ulp_fsm_common.h基址0x1200。当程序中不含任何宏macro_count 0时函数退化为直接memcpy到RTC_SLOW_MEM load_addr不做任何重定位。ulp_run 启动流程ulp_run(entry_point)的实现位于 components/ulp/ulp_fsm/ulp.c。它依次完成复位/释放 ULP 协处理器、停用 ULP 定时器、等待至少 1 个RTC_SLOW_CLK周期、将入口点写入 PC 初始化寄存器ESP32 为SENS_SAR_START_FORCE_REG的SENS_PC_INIT字段ESP32-S2/S3 为RTC_CNTL_ULP_CP_TIMER_REG的RTC_CNTL_ULP_CP_PC_INIT字段、使能 ULP 定时器以周期性地启动程序。ULP 定时器每计数到设定的RTC_SLOW_CLK周期数ESP32 默认内部 150 kHz RC 振荡器ESP32-S2/S3 为 90 kHz就从上一次ulp_run设置的入口点开始执行程序直至遇到halt指令后再次启动定时器如此循环。宏编程与汇编编程的取舍宏编程方式与传统的 ULP 汇编通过ulp_riscv/ulp_fsm子工程 binutils 工具链编译再用ulp_load_binary装载流程见 docs/en/api-reference/system/ulp-fsm.rst相比各有适用场景宏编程的优势不需要为 ULP 单独建立 CMake 子工程、不需要生成并嵌入二进制 blob程序与主应用代码同文件便于就地维护标签由ulp_process_macros_and_load自动重定位无需手工计算跳转偏移。宏编程的局限指令宏展开依赖 C 编译器初始化语义因此数组不能放在全局作用域不支持复杂的汇编伪指令、宏汇编与多文件组织对大型程序的可维护性不如独立汇编/编译子工程。在需要深度睡眠期间执行周期性的传感器采样、GPIO 状态机控制且程序规模较小例如 100 条指令的场景下宏编程是一种轻量、直观、易验证的选择——仓库中的测试用例test_ulp.c、test_ulp_manual.c便是其典型应用与验证方式。参考本文核心来源docs/en/api-reference/system/ulp_macros.rstULP FSM 总体说明与汇编编程docs/en/api-reference/system/ulp-fsm.rst指令集详解docs/en/api-reference/system/ulp_instruction_set.rst指令宏与类型定义源码components/ulp/ulp_fsm/include/esp32/ulp.h宏解析实现components/ulp/ulp_fsm/ulp_macro.c装载与运行实现components/ulp/ulp_fsm/ulp.c测试用例components/ulp/test_apps/ulp_fsm/main/test_ulp.c、components/ulp/test_apps/ulp_fsm/main/test_ulp_manual.c【免费下载链接】esp-idfEspressif IoT Development Framework. Official development framework for Espressif SoCs.项目地址: https://gitcode.com/GitHub_Trending/es/esp-idf创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考