前言
这两年 AI 发展得很快。作者现在写代码,很多是让 AI 写的;写博客也一样,把想法丢给 AI,让它先写一稿,作者再改。
后来作者和 AI 讨论了一下原有的架构,然后设计了新的架构。这个系列叫"重新写一遍单片机程序",把新架构和重做的过程记下来,第一篇先拿 GPIO 开刀。
旧系列那篇《点亮led灯》是两年前写的。那套写法在项目里用了挺长时间,灯该亮亮、该灭灭,没出过什么大问题。当时作者还挺满意,觉得自己的水平还可以。后来和 AI 讨论完才发现,问题有很多。
这篇的文字也是 AI 帮忙整理的。
1 旧代码的问题
旧代码用结构体加函数指针把 GPIO 做成了对象,作者现在也还是这个做法。不过旧代码有几处问题。
旧系列里说过"换板子只改 board 层"。作者后来换芯片的时候发现,改的远不止 board:旧代码的 board 文件里直接写着 GPIO_Init、GPIO_StructInit、RCC_APB2PeriphClockCmd,板级表里存的是 GPIO_TypeDef* 和 RCC_APB2Periph_*,全是 ST 标准外设库的东西。换一颗芯片,这些代码得重写,不是换个表就完事。
Init、Control、GetStatus 三个函数指针在每个 Gpio_t 里都存一遍,一共 12 字节。同一类对象这三个地址永远一样,存一份就够了。
GpioCreat 里 malloc 了一个对象,头文件里却没有 Delete。作者自己踩过一次:连着创建两次,第二次把第一次的指针覆盖掉,泄漏了一个对象。当时查了一阵子才找到。
电平的事也麻烦。点亮红灯要写 GPIO_STATU_LOW,因为板子上的灯是低电平点亮的。作者知道这个,但换个写应用的人来,得先翻原理图。哪天换一块高电平点亮的板子,应用层还得跟着改。
名字也是个问题。叫 Gpio,其实只有输出。GetStatus 读的是 GPIO_ReadOutputDataBit,输出寄存器,接一个输入脚上去读到的值没有意义。
旧代码的大部分问题,是上面的代码直接依赖了下面具体的东西。作者重做的时候,第一件事就是把这些依赖拆开。
2 新框架
作者现在把代码分成几层:
app | module | +-----+-----+ device board <- 合称 bsp | | v v ops-if (只写板级事实) ^ | chip <- 唯一允许出现芯片符号的层 device / board ---> os-if <--- os-freertos 一拨跟芯片有关:ops-if 是接口,chip 是实现。接口里只写要提供什么能力,一个字不提 STM32;换芯片的时候只动 chip。
一拨跟板子有关:board 只写板子上怎么接的——哪个端口、哪个脚、推挽还是开漏、高电平有效还是低电平有效。板级表里不允许出现芯片符号。
另外还有 os 那一拨,bsp 要用临界区、内存、事件的时候走 os-if,os-freertos 是它的实现。这篇用不到,先不说。
目录也是按这个分的,目录名就是 include 的前缀:
Demo/User/ ops/ 接口契约(只有 .h) chip/ 芯片实现 board/ 板级表 device/ 设备类 module/ 业务模块(日志等) os/ os 接口 os-freertos/ os 实现 Keil 工程里只加一个 include 根 Demo/User,以后加新层不用改工程。
2.1 接口
接口层不需要知道 GPIO_TypeDef,只需要知道端口加位掩码:
typedef enum { OPS_PORT_A = 0, OPS_PORT_B, OPS_PORT_C, OPS_PORT_D, OPS_PORT_E, /* ... F/G/H ... */ OPS_PORT_MAX, }OpsPort_t; typedef struct { OpsPort_t Port; uint16_t Pin; /* 位掩码,如 OPS_PIN(5) */ }OpsPin_t; #define OPS_PIN(n) ((uint16_t)(1u << (n))) 然后是能力表。这篇只讲前后五个方法,复用功能以后单独开一篇:
typedef struct { OpsStatus_t (*Init)(OpsPin_t pin, OpsGpioMode_t mode); OpsStatus_t (*Write)(OpsPin_t pin, uint8_t level); uint8_t (*Read)(OpsPin_t pin); OpsStatus_t (*Toggle)(OpsPin_t pin); OpsStatus_t (*WriteMask)(OpsPort_t port, uint16_t mask, uint16_t value); /* 复用功能:按用途语义化,不暴露 AF 编号 */ OpsStatus_t (*InitUart)(uint8_t uartId, const OpsUartPin_t* pins, uint8_t count); OpsStatus_t (*InitSpi)(uint8_t spiId, const OpsSpiPin_t* pins, uint8_t count); OpsStatus_t (*InitI2c)(uint8_t i2cId, const OpsI2cPin_t* pins, uint8_t count); }OpsGpioOps_t; OpsGpioMode_t 里只有浮空输入、上拉、下拉、推挽、开漏、模拟这些语义,没有 STM32 的 GPIO_Mode_Out_PP,转换由芯片层负责。
接口层不声明表本身,表放在实现层,并且加了 static。要拿表只能调这个函数:
/* 表本身在实现层是 static,别处引用不到它的名字; 这是唯一拿到它的途径 */ const OpsGpioOps_t* OpsGpioGetOps(void); 如果表是全局符号,谁都可以直接 g_opsGpio.Write(...),分层就白做了;换实现也只能靠链接期替换,测试里塞不进假表。
2.2 芯片实现
这个文件是唯一允许出现 stm32f10x.h 的地方,只做一件事:把接口语义转换成 STM32F1 的寄存器操作。
static const OpsGpioOps_t ChipGpioOps = { .Init = ChipGpioInit, .Write = ChipGpioWrite, .Read = ChipGpioRead, .Toggle = ChipGpioToggle, .WriteMask = ChipGpioWriteMask, .InitUart = ChipGpioInitUart, .InitSpi = ChipGpioInitSpi, .InitI2c = ChipGpioInitI2c, }; const OpsGpioOps_t* OpsGpioGetOps(void) { return &ChipGpioOps; } 表是 static 的,别的 .c 文件引用不到 ChipGpioOps 这个名字,只能通过 OpsGpioGetOps() 拿指针。
写一个引脚:
static OpsStatus_t ChipGpioWrite(OpsPin_t pin, uint8_t level) { GPIO_TypeDef* port; port = ChipGpioPortGet(pin); if (NULL == port) { return OPS_ERR_PARAM; } if (0 != level) { GPIO_SetBits(port, pin.Pin); } else { GPIO_ResetBits(port, pin.Pin); } return OPS_OK; } 时钟使能也收在这一层,板级不用管:
/* 时钟使能收在本层,板级不必关心 */ ChipClockEnable(ChipPortToClock[pin.Port]); 还有个细节,STM32F1 没有独立的翻转寄存器,所以 Toggle 是读 ODR 再反着写:
if (0 != (port->ODR & pin.Pin)) { GPIO_ResetBits(port, pin.Pin); } else { GPIO_SetBits(port, pin.Pin); } 文件开头有个版本校验,接口层的 OPS_API_VERSION 和实现层的 OPS_IMPL_API_VERSION 对不上就直接编译失败:
#define OPS_IMPL_API_VERSION (3u) #if OPS_IMPL_API_VERSION != OPS_API_VERSION #error "ops-if 接口版本与本 chip 实现不一致,请同步 ChipGpioXxx" #endif 这个是被坑出来的。接口加了方法,实现没跟上,运行时才发现某个函数指针是空的。
2.3 板级表
板级层写的是板子上的这个输出怎么接。
typedef enum { DOUT_LED_RED = 0, /* 板上红灯 */ DOUT_LED_GREEN, /* 板上绿灯 */ DOUT_LED_BLUE, /* 板上蓝灯 */ DOUT_MAX, }BoardDoutId_t; typedef struct { OpsPin_t Pin; /* 端口 + 位掩码 */ OpsGpioMode_t Mode; /* 推挽 / 开漏 */ uint8_t ActiveLevel; /* 有效电平:0 = 低电平有效(拉低点亮),1 = 高电平有效 */ }BoardDoutPin_t; extern const BoardDoutPin_t BoardDoutPins[DOUT_MAX]; 表长这样:
const BoardDoutPin_t BoardDoutPins[DOUT_MAX] = { [DOUT_LED_RED] = { { OPS_PORT_B, OPS_PIN(5) }, OPS_GPIO_OUT_PP, 0 }, [DOUT_LED_GREEN] = { { OPS_PORT_B, OPS_PIN(0) }, OPS_GPIO_OUT_PP, 0 }, [DOUT_LED_BLUE] = { { OPS_PORT_B, OPS_PIN(1) }, OPS_GPIO_OUT_PP, 0 }, }; 有效电平是作者最满意的一个字段。它把"拉低点亮"这件板级事实写进了表里,设备层据此做换算,上层写 Write(1) 永远表示有效,灯亮、继电器吸合,不用管板子是高电平还是低电平点亮。
还有个写法上的小改动:用 C99 的指定初始化器 [DOUT_LED_RED] = {...},不是旧代码那种按顺序排下来的宏。顺序可以随便调,也不会出现改表时标识和引脚错位的低级错误。
全项目需要按板子改的文件只有这一个。
2.4 设备对象
先看对象:
struct DevDout_t { OpsPin_t Pin; /* 由标识解析出的引脚 */ const DevDoutOps_t* Ops; /* 方法表 */ DoutId_t Id; /* 板级标识 */ uint8_t ActiveLevel; /* 有效电平,来自板级表 */ }; 方法表只有一份,对象里只存一个指针。
逻辑值和物理电平的换算挪到了设备层:
static uint8_t DoutLevelToPin(uint8_t activeLevel, uint8_t level) { if (0 != level) { return activeLevel; /* 写 1 = 有效 = 板子规定的有效电平 */ } return (uint8_t)(0 != activeLevel ? 0u : 1u); } static OpsStatus_t DoutWrite(DevDout_t* self, uint8_t level) { if (NULL == self) { return OPS_ERR_NULL; } return DoutEnv.Chip->Write(self->Pin, DoutLevelToPin(self->ActiveLevel, level)); } static uint8_t DoutRead(DevDout_t* self) { if (NULL == self) { return 0; } /* 引脚电平 -> 逻辑值:与有效电平相同即为"有效" */ return (DoutEnv.Chip->Read(self->Pin) == self->ActiveLevel) ? 1 : 0; } 创建和删除是这样:
DevDout_t* DoutCreate(DoutId_t id) { DevDout_t* self; /* 设备层自己取能力:表在实现层是 static,只能经接口层的访问函数拿 */ DoutEnv.Chip = OpsGpioGetOps(); DoutEnv.Mem = OsMemGetOps(); if ((NULL == DoutEnv.Chip) || (NULL == DoutEnv.Mem)) { return NULL; } if ((id >= DOUT_MAX) || (0 == BoardDoutPins[id].Pin.Pin)) { return NULL; } if (NULL != DoutObjects[id]) { return DoutObjects[id]; /* 幂等:已创建过的直接给回来 */ } self = (DevDout_t*)DoutEnv.Mem->Alloc((uint32_t)sizeof(DevDout_t)); if (NULL == self) { return NULL; } self->Id = id; self->Pin = BoardDoutPins[id].Pin; self->ActiveLevel = BoardDoutPins[id].ActiveLevel; self->Ops = &DoutOps; if (OPS_OK != DoutEnv.Chip->Init(self->Pin, BoardDoutPins[id].Mode)) { DoutEnv.Mem->Free(self); /* 硬件没配上:把内存还回去,允许下次重试 */ return NULL; } /* 先置为无效(灯灭):复位后引脚是低电平,"拉低点亮"的板子 不这么做的话上电瞬间就是亮的 */ if (OPS_OK != DoutEnv.Chip->Write(self->Pin, DoutLevelToPin(self->ActiveLevel, 0u))) { DoutEnv.Mem->Free(self); return NULL; } DoutObjects[id] = self; return self; } 设备层在 DoutCreate 里调 OpsGpioGetOps() 和 OsMemGetOps(),应用层不用装配什么。测试的时候换一张假表就能在 PC 上跑,不用板子。
创建的时候先把输出置成无效。复位以后引脚默认是低电平,低电平点亮的板子不做这一步,上电瞬间三个灯会一起闪一下。这个是在板子上看到的。
删除就是 obj->Ops->Delete(obj),只归还内存,不动引脚电平。要断电就先 Write 成无效,顺序由调用方决定。
2.5 应用层怎么用
应用层的代码和旧写法差不多,但语义清楚了:
#include "device/dev_dout.h" #include "board/board_dout.h" void LedTask(void* parameter) { DevDout_t* redLed = NULL; redLed = DoutCreate(DOUT_LED_RED); if (NULL == redLed) { while (1); } while (1) { redLed->Ops->Write(redLed, 1); /* 亮 */ vTaskDelay(200); redLed->Ops->Write(redLed, 0); /* 灭 */ vTaskDelay(800); } } 对比旧写法:
redLed->Control(redLed, GPIO_STATU_LOW); /* 亮 —— 得知道灯是低电平点亮 */ 现在的 Write(1) 就是亮,板子是低电平还是高电平点亮,应用层不用知道。
3 怎么保证不走样
分层靠自觉不行,作者加了三道检查,改完代码就跑。
一个脚本扫每一层源码的 include 前缀:device 层只允许引用 ops/os/board/device;board 层不允许出现 chip 符号;device、module、app 层不许出现 g_opsXxx、g_osXxx 这类全局表符号。同一个脚本还查禁用拼写,Creat、Statu 这些词都会被拦下来,文件编码也查。
另一个脚本按层独立编译。每层给固定的 include 白名单,用 armcc 单独编。某层如果 include 了上游的头,这一步就会失败。
第三个是 PC 端单元测试。把真实的 dev_dout.c 和 board_dout.c 跟一张假的能力表编成 Windows 程序直接跑,板子都不用接。dev_dout 这套现在有 34 项断言,覆盖对象按需申请、重复创建幂等、申请失败不留半成品、删除后重建、极性与读写翻转、越界与空指针这些。
测试第一次跑起来就崩了。查下去是创建对象时先申请内存、后复制环境,结果拿空环境去申请内存,空指针。这个 bug 在板子上跑就是莫名其妙死机,在 PC 上几秒钟就定位了。
结语
这篇就到这。代码在作者的板子上跑过:三色灯能亮能灭,对象删了再建也正常。板子是野火指南者(STM32F103VE),用的是标准外设库和 Keil ARMCC V5;换别的库或者别的芯片,chip 那一层要重写,board 和 device 不用动。
代价是每次调用多一次函数指针跳转,几个周期,点灯无所谓,真要放进高频中断里就得单独算。文件也比原来多了不少,还要维护三个检查脚本。小项目、一个人写、不打算换硬件,就别折腾了。
下一篇打算写串口,收发都是环形队列,窗口交给芯片层搬,DMA 和逐字节两条路用同一套接口。