基于 RTOS 信号量实现多核(AMP)架构下串口打印互斥的陷阱与替代方案

引言

在嵌入式多核系统中,AMP(非对称多处理)架构下每个核运行独立的 RTOS(如 FreeRTOS 或 RT-Thread),但共享外设(如 UART)时,必须实现互斥访问。许多开发者习惯性地使用 RTOS 信号量(Semaphore)来保护串口打印,然而在多核场景下,这种方案往往导致性能下降、死锁甚至数据损坏。本文将深入分析信号量在跨核互斥中的缺陷,并提供更可靠的替代方案。

信号量在多核 AMP 中的本质问题

1. 信号量的实现依赖单核调度

RTOS 信号量(如 FreeRTOS 的 xSemaphoreTake)通常通过关中断和任务调度器实现临界区保护。在单核系统中,这能保证原子性。但在多核 AMP 中,每个核独立运行调度器,一个核上的信号量操作无法阻止另一个核同时访问共享资源。例如,当核 A 持有信号量时,核 B 可能并不知道,因为信号量的内部状态(如计数器和等待队列)仅存在于核 A 的内存空间中,除非显式使用跨核同步机制(如硬件锁)。

2. 优先级反转与死锁风险

若两个核上的任务以不同优先级竞争同一信号量,低优先级任务持有信号量时,高优先级任务可能被阻塞,而低优先级任务又可能被其核上的更高优先级任务抢占,导致优先级反转。更严重的是,如果高优先级任务在等待信号量时持有另一个核需要的资源,可能形成循环等待,造成死锁。

3. 中断上下文中的不可用性

在中断服务程序(ISR)中,不能调用阻塞型信号量 API(如 xSemaphoreTake),否则会导致系统崩溃。但在多核系统中,串口中断可能在任意核上触发,若 ISR 需要打印日志,则无法使用信号量进行互斥。

替代方案:硬件锁与原子操作

方案一:使用硬件自旋锁(Spinlock)

许多 MCU(如 Cortex-A 系列)提供硬件自旋锁(如 ARM 的 exclusive 指令或专用锁寄存器)。自旋锁通过原子操作实现跨核互斥,不依赖 RTOS 调度器。

原理:每个核在进入临界区前,通过原子指令(如 LDREX/STREX)尝试获取锁,若失败则循环重试(自旋),直到成功。

优点

  • 跨核原子性由硬件保证,不受 RTOS 调度影响。
  • 可在中断上下文中使用(只要不长时间持有)。

缺点

  • 自旋等待会浪费 CPU 周期,不适合长时间持有锁。
  • 需要确保临界区代码极短(如仅写入 FIFO)。

示例代码(基于 ARM Cortex-A9 的 GCC 内联汇编):

// 自旋锁结构体
typedef struct {
    volatile int locked;
} spinlock_t;

// 获取锁(原子比较交换)
void spin_lock(spinlock_t *lock) {
    while (__atomic_test_and_set(&lock->locked, __ATOMIC_ACQUIRE)) {
        // 自旋等待
    }
}

// 释放锁
void spin_unlock(spinlock_t *lock) {
    __atomic_clear(&lock->locked, __ATOMIC_RELEASE);
}

// 全局串口锁
static spinlock_t uart_lock;

void uart_print(const char *str) {
    spin_lock(&uart_lock);
    // 写入 UART FIFO(确保原子操作)
    while (*str) {
        while (!(UART->SR & TX_READY));
        UART->DR = *str++;
    }
    spin_unlock(&uart_lock);
}

方案二:使用原子操作实现无锁环形缓冲区

如果打印数据量不大,可以设计一个多生产者单消费者的无锁环形缓冲区。每个核将日志写入缓冲区,由一个专用核(如核 0)负责从缓冲区读取并输出到串口。

原理:使用原子读改写指令(如 atomic_fetch_add)更新写索引,避免锁竞争。

优点

  • 无锁,避免死锁和优先级反转。
  • 写入操作极快,适合高频日志。

缺点

  • 需要额外内存,且消费者必须及时处理,否则缓冲区溢出。

示例代码(使用 C11 原子操作):

#include <stdatomic.h>

#define BUF_SIZE 256
static char buffer[BUF_SIZE];
static atomic_int head = 0; // 写索引
static int tail = 0;        // 读索引(仅消费者访问)

// 生产者(任意核调用)
void log_write(const char *data, int len) {
    int h = atomic_load_explicit(&head, memory_order_relaxed);
    int next = (h + len) % BUF_SIZE;
    // 检查空间(简化,实际需处理溢出)
    for (int i = 0; i < len; i++) {
        buffer[(h + i) % BUF_SIZE] = data[i];
    }
    atomic_store_explicit(&head, next, memory_order_release);
}

// 消费者(核 0 的专用任务)
void uart_consumer_task(void) {
    while (1) {
        int h = atomic_load_explicit(&head, memory_order_acquire);
        while (tail != h) {
            // 输出字符到 UART
            uart_putc(buffer[tail]);
            tail = (tail + 1) % BUF_SIZE;
        }
        // 等待或调度
    }
}

方案三:专用打印服务(串口代理)

在 AMP 架构中,可以指定一个核(如主核)独占串口,其他核通过核间通信(如 Mailbox)发送日志数据。主核负责将数据打印到串口。

优点

  • 串口访问完全串行化,无竞争。
  • 其他核无需关心互斥逻辑。

缺点

  • 核间通信有延迟,不适合高频日志。
  • 需要实现 IPC 机制,增加复杂度。

示例(使用简单共享内存队列):

// 共享内存队列(每个核有独立队列)
#define MSG_SIZE 128
struct msg_queue {
    char data[MSG_SIZE];
    atomic_int len;
} queues[NUM_CORES];

// 发送端(核 X)
void send_log(const char *msg) {
    int core_id = get_core_id();
    // 将 msg 复制到 queues[core_id].data,并设置 len
    atomic_store(&queues[core_id].len, strlen(msg));
    // 触发主核中断(Mailbox)
    trigger_mailbox(0);
}

// 主核处理
void mailbox_isr(void) {
    for (int i = 0; i < NUM_CORES; i++) {
        if (atomic_load(&queues[i].len) > 0) {
            uart_print(queues[i].data);
            atomic_store(&queues[i].len, 0);
        }
    }
}

配置步骤(以 FreeRTOS + 双核 AMP 为例)

  1. 确定硬件支持:检查 MCU 是否提供硬件自旋锁或原子指令(如 Cortex-M 的 LDREX/STREX 或 Cortex-A 的 exclusive)。
  2. 选择方案:若打印频率低且临界区短,使用自旋锁;若频率高,使用无锁环形缓冲区;若需要严格顺序,使用专用打印服务。
  3. 实现互斥原语:根据方案编写自旋锁或原子操作封装。
  4. 集成到打印函数:替换原有信号量保护代码。
  5. 测试:多核同时打印大量日志,验证无数据交错、无死锁。

注意事项

  • 避免长时间持有锁:自旋锁临界区应只包含寄存器写入操作,不要包含耗时函数(如 printf 格式化)。
  • 内存屏障:跨核访问共享变量时,务必使用内存屏障(如 __atomic__sync_synchronize)防止编译器/CPU 重排。
  • 中断安全:如果打印函数可能在 ISR 中调用,确保使用的原语是中断安全的(如自旋锁可关中断)。
  • 调试工具:使用逻辑分析仪观察串口波形,验证无字节交错。
  • 性能权衡:自旋锁会浪费 CPU,若临界区较长,考虑使用无锁队列或专用服务。

总结

在多核 AMP 架构下,RTOS 信号量并非跨核互斥的银弹。其单核实现本质导致无法保证原子性,并可能引发死锁。硬件自旋锁、无锁环形缓冲区和专用打印服务是更可靠的替代方案。开发者应根据实际场景(打印频率、临界区长度、中断使用)选择合适方案,并注意内存屏障和中断安全。掌握这些技术,能显著提升多核系统的稳定性和调试效率。