news 2026/9/15 21:55:03

GD32H759 + RT-Thread 以太网驱动移植实战:从 DMA 描述符到 PHY 调试

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
GD32H759 + RT-Thread 以太网驱动移植实战:从 DMA 描述符到 PHY 调试

做工控这块的兄弟应该都有同感:设备能不能联网,直接影响整个项目方案怎么定。上篇把GD32H759 + RT-Thread的环境、时钟、串口这些地基搞定了,这篇咱们就啃最硬的骨头——以太网驱动(enet)。说实话,以太网驱动在所有外设驱动里算是最绕的,不仅涉及MAC控制器、PHY芯片、DMA描述符,还要挂到RT-Thread的网络协议栈上,任何一个环节没配对,现象都是千奇百怪。这篇文章我会把自己从零移植enet驱动的完整思路、关键代码逻辑、以及调试时踩过的坑全部梳理一遍,希望能帮正在用GD32H759做工控联网的兄弟少走弯路。

这篇适合两类人:一是刚接触RT-Thread网络开发,想搞清楚网卡驱动到底怎么挂到系统上的;二是已经在裸机上把enet跑通,但不太理解DMA描述符、PHY管理、中断收包这些机制,想系统理一遍的。内容不会只给结论,会把“为什么这么做”也讲清楚,毕竟驱动这种东西,不懂原理就只能靠试,试错成本太高了。

1. 项目整体设计与选型思考

1.1 为什么选GD32H759跑工控以太网

GD32H759这颗芯片在兆易创新的产品线里属于高性能旗舰,Cortex-M7内核、600MHz主频、2MB Flash、1MB SRAM,板上直接集成10/100M以太网MAC,还能接外部PHY走MII或RMII。这个配置放在工业控制器、边缘采集网关、协议转换器这类场景里,性能是足够的,没必要上Linux主控,MCU方案从成本、启动速度、生态上都有优势。

之前我做过一个项目,需要在一个设备上同时采集现场传感器数据、跑Modbus TCP从站、还要通过MQTT把数据上报到上位机。如果选普通M4芯片,以太网虽然也能跑,但遇到大流量加协议栈开销,CPU占用会偏高;选A系列应用处理器,开发复杂度又上来了,光系统移植和驱动适配就要多花几周时间。GD32H759正好卡在中间,M7内核单核主频高,SRAM大到可以给每个DMA描述符预留专门的缓存区,RT-Thread的实时性又能保证数据采集和协议处理在确定时间内完成,这就是选型逻辑。

1.2 驱动到底该写在RT-Thread的哪个层级

很多刚从裸机转过来的朋友会困惑:我在裸机上写了一个en以太网驱动,怎么在RT-Thread里就找不着北了?其实核心问题在于,任何RTOS下做驱动,都需要先搞清楚“你的驱动要服务给谁”。

RT-Thread对网络设备有一个标准抽象层,网卡驱动不直接跟应用通信,而是先注册成一个以太网设备,再挂在lwIP协议栈下面。lwIP负责TCP/IP协议处理,应用层我调用socket接口收发数据,底层实际由网卡驱动完成数据包的搬移。

所以整体结构是这么几层:

  1. 应用层:一般用RT-Thread的SAL层统一socket接口,不关心底层是lwIP还是其他协议栈。
  2. 协议栈层:lwIP向上提供协议接口,向下调用以太网设备抽象层。
  3. 设备层:实现RT-Thread的eth_device接口,包括初始化、打开/关闭、收发函数、链路状态查询等。
  4. 底层驱动:真正操作GD32H759的寄存器和DMA描述符,以及通过MDIO总线配置PHY。

写驱动时,重点其实在底层驱动这一层,但必须把上层接口的语义搞清楚。比如RT-Thread里的eth_device_ops会有init、open、close、read、write、control等函数,我需要把这些函数一一对应到GD32H759的硬件操作上,而不是自己裸调寄存器。

2. enet硬件机制拆解:从MAC到PHY的完整链路

2.1 一次以太网数据发送,数据到底是怎么走的

要写好驱动,先得把以太网控制器的工作机制理清。以太网数据链路分成两大部分:MAC控制器和PHY芯片。

MAC控制器在芯片内部,负责数据帧的组装/解析、地址过滤、CRC校验,以及对外提供MII/RMII接口信号。PHY芯片则是物理层收发器,负责把数字信号变成模拟电信号发到网线上,同时负责链路协商、速度/双工模式检测。

这样说有点抽象,我打个比方。MAC相当于一个负责包装和拆包的仓库管理员,PHY相当于送货的快递员。我要发货,仓库管理员(MAC)把货物装入标准箱子并贴上地址(添加以太网帧头和CRC),交给快递员(PHY),快递员用汽车/飞机(电信号)把箱子送到目的地。收件时反过来,快递员把箱子交给仓库管理员,管理员验货、拆包、交给我。

RMII接口是MAC和PHY之间的通信协议,相比完整MII接口,RMII引脚少,总共才7根信号线左右:TXD0/TXD1、TX_EN、RXD0/RXD1、CRS_DV、REF_CLK。GD32H759开发板上一般默认用RMII,因为10/100M速度下RMII完全够用,而且省IO。需要注意RMII要求一个50MHz参考时钟,这个时钟可以由外部晶振独立提供,也可以由PHY芯片产生后反向给MAC,具体看硬件设计。

2.2 DMA描述符环:驱动效率的根本

GD32H759的enet有一个内部DMA控制器,不占用CPU逐个拷贝数据帧。CPU只需要在RAM里准备一块“描述符表”,每个描述符记录一个数据缓冲区地址、长度、状态标志位。描述符之间通过指针链成环形,DMA会自动按顺序访问。

一个发送描述符大致包含:发送缓冲区地址、发送字节数、帧校验、中断标志、所有权位。所有权位特别关键,它决定描述符当前归CPU还是归DMA所有。初始化时描述符归CPU所有,我准备好数据后把所有权交给DMA,DMA发完数据后再把所有权还给CPU。接收方向相反,DMA收到数据后先把数据写入接收缓冲区,再把所有权交还给CPU,并置位中断标志位通知我来处理。

这里的高效点在于:如果描述符环足够大,DMA可以在CPU处理旧数据的同时继续接收新数据,形成流水线。工控设备经常要处理突发的报警帧,描述符环配小了就容易丢包。我在项目里接收描述符一般配16个,发送描述符配8个,每个缓冲区128字节,凑足一个缓存池,够大多数工控场景用了。

2.3 MDIO总线:驱动与PHY通信的桥梁

MAC除了收发数据,还要通过MDIO总线管理PHY。MDIO只有两根线:MDC时钟线、MDIO数据线。MDC由MAC驱动,频率一般控制在2.5MHz以内。MDIO线上可以挂多个PHY,每个PHY有独立的5位物理地址,所以最多挂32个器件。

实际应用中,我至少要做两件事:读PHY的基本ID确认PHY型号,读/写PHY的控制寄存器和状态寄存器。比如PHY的寄存器0(控制寄存器)里可以配置自动协商、软复位、速度双工模式;寄存器1(状态寄存器)里有协商结果、链路状态、远端是否有支持的能力。检测网线是否插好,就是周期读这个状态位。

有的驱动会把MDIO操作做成统一函数,比如mdio_read(phy_addr, reg)mdio_write(phy_addr, reg, val),底层是模拟GPIO或使用MAC的MDIO控制器硬件时序。GD32H759的MAC自带MDIO控制器,操作寄存器就能完成时序,不需要软件模拟,省了很多事。

3. RT-Thread enet驱动移植实操

3.1 驱动文件怎么组织、从哪里抄

一般情况下,我不会完全从零写驱动文件。GD32官方固件库已经提供了基于HAL风格的enet驱动,RT-Thread官方bsp里也往往有对应芯片的驱动模板,我的经验是先复制一份官方模板,再根据自己板子的连接方式改。

RT-Thread工程里,我会在board目录下单独建一个enet文件夹,放这几个文件:

  • gd32h7xx_enet.c:官方固件库的enet底层驱动,主要操作MAC和DMA寄存器。
  • drv_eth.c:负责把底层接口封装成RT-Thread的eth_device。
  • phy_xxx.c:自己的PHY芯片驱动,提供复位、协商、读取状态函数。

关键是要清楚哪些地方需要手动改。官方模板一般把引脚复用、PHY地址、DMA描述符数量定义在一个配置头文件里,我会在board.h或者eth_config.h里统一改。

3.2 第一步:引脚复用和RMII初始化

我用的板子PHY连接方式是RMII,引脚大概这样分配:

  • ETH_RMII_TXD0、ETH_RMII_TXD1:发送数据
  • ETH_RMII_TX_EN:发送使能
  • ETH_RMII_RXD0、ETH_RMII_RXD1:接收数据
  • ETH_RMII_CRS_DV:载波监听/数据有效
  • ETH_RMII_REF_CLK:50MHz参考时钟
  • ETH_MDC、ETH_MDIO:MDIO管理接口
  • PHY_RESET:复位引脚,一般是普通GPIO控制

配置引脚复用,在GD32标准外设库里大概是这样:

void enet_gpio_config(void) { /* 使能相关GPIO时钟 */ rcu_periph_clock_enable(RCU_GPIOA); rcu_periph_clock_enable(RCU_GPIOB); rcu_periph_clock_enable(RCU_GPIOC); rcu_periph_clock_enable(RCU_AF); /* 以PA口对应RMII信号为例 */ gpio_af_set(GPIOA, GPIO_AF_11, GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_7); /* ETH_RMII_REF_CLK等 */ gpio_mode_set(GPIOA, GPIO_MODE_AF, GPIO_PUPD_NONE, GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_7); gpio_output_options_set(GPIOA, GPIO_OTYPE_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_7); /* 其他引脚同理 */ /* PHY复位引脚,先拉低再拉高完成复位 */ gpio_bit_reset(PHY_RST_PORT, PHY_RST_PIN); delay_ms(50); gpio_bit_set(PHY_RST_PORT, PHY_RST_PIN); delay_ms(50); }

这里有个坑:不同开发板PHY接的RMII参考时钟可能不同,有的PHY自己输出50MHz给MCU,有的需要外部有源晶振。初始化顺序不对会导致PHY工作不稳定。我试过先把PHY复位拉低、再配置MCU引脚,结果RMII时钟老是对不上,排查了很久,后来按PHY上电、复位、稳定参考时钟、再初始化MAC这个顺序才稳。

3.3 第二步:DMA描述符环的初始化

DMA描述符环是驱动里最核心的数据结构,必须保证在内存中连续且对齐。最好的做法是定义成全局数组,放在BSS段里,然后手动检查地址对齐。

#define ENET_RX_DESC_NUM 16 #define ENET_TX_DESC_NUM 8 #define ENET_RXBUF_SIZE 128 #define ENET_TXBUF_SIZE 128 static struct enet_desc rx_desc[ENET_RX_DESC_NUM]__attribute__((aligned(4))); static struct enet_desc tx_desc[ENET_TX_DESC_NUM]__attribute__((aligned(4))); static uint8_t rx_buffer[ENET_RX_DESC_NUM][ENET_RXBUF_SIZE]__attribute__((aligned(4))); static uint8_t tx_buffer[ENET_TX_DESC_NUM][ENET_TXBUF_SIZE]__attribute__((aligned(4)));

初始化描述符的要点就是把每个描述符的next指针串起来,最后一个描述符的next指向第一个,形成环。每个接收描述符的buffer_addr指向rx_buffer,control字段设置接收缓冲区大小和所有权位。

void enet_desc_init(void) { uint32_t i; for (i = 0; i < ENET_RX_DESC_NUM; i++) { rx_desc[i].status = ENET_RX_DESC_OWNED_BY_DMA; rx_desc[i].control = ENET_RX_DESC_BUFFER_SIZE(ENET_RXBUF_SIZE) | ENET_RX_DESC_CHAIN_MODE; rx_desc[i].buffer_addr = (uint32_t)rx_buffer[i]; rx_desc[i].next_addr = (uint32_t)&rx_desc[(i + 1) % ENET_RX_DESC_NUM]; } for (i = 0; i < ENET_TX_DESC_NUM; i++) { tx_desc[i].status = ENET_TX_DESC_OWNED_BY_CPU; tx_desc[i].control = ENET_TX_DESC_CHAIN_MODE; tx_desc[i].buffer_addr = (uint32_t)tx_buffer[i]; tx_desc[i].next_addr = (uint32_t)&tx_desc[(i + 1) % ENET_TX_DESC_NUM]; } }

这里要特别提醒:M7内核带D-Cache,描述符结构和缓冲区在RAM里会被CPU缓存。DMA控制器不经过缓存直接访问RAM,如果描述符被缓存在CPU中而DMA又同时在改它,数据就会出现“新老版本不一致”。针对这个问题,GD32官方有的例程会建议关闭D-Cache或把内存配置为不缓存,但这会损失性能。

更优雅的做法是:在关键描述符操作前做Cache Clean,操作后做Cache Invalidate。RT-Thread在某些bsp里会做统一封装。我建议不管你的驱动模板有没有处理,先确认一下。

3.4 第三步:MAC初始化和PHY自动协商

MAC初始化主要是配置帧过滤、全双工、100M速度、硬件CRC校验、MAC地址等。帧过滤这块工控场景值得多说一句:MAC可以设置接收所有帧(promiscuous mode),也可以根据目的MAC地址过滤。工控从站通常只需要接收发给自己或广播的帧,我一般不开混杂模式,避免无关帧大量占用DMA描述符。

void enet_mac_config(void) { enet_mac_deinit(); enet_mac_speed_config(ENET_MAC_SPEED_100M); enet_mac_duplex_config(ENET_MAC_FULL_DUPLEX); /* 设置MAC地址,例如 00:80:E1:xx:xx:xx */ enet_mac_address_set(ENET_MAC_ADDR0, mac_addr); /* 使能MAC发送、接收 */ enet_mac_frame_filter_config(ENET_FRAME_FILTER_NONE); enet_mac_update_config(ENET_RX_MODE_BROADCAST, ENET_RX_MODE_MULTICAST, ENET_RX_MODE_UNICAST); }

PHY配置这里,我最常用的是PHY自动协商模式。让PHY和交换机/对端设备自动协商速度与双工。启动自动协商后,需要等待几秒让PHY稳定,再读取状态寄存器确认链路已经up。

void phy_init(void) { phy_reset(); delay_ms(100); /* 配置PHY控制寄存器,开启自动协商,使能全双工和100M能力 */ uint16_t reg = phy_read(PHY_REG_CONTROL); reg |= (1 << 12); /* 自动协商使能 */ reg |= (1 << 13); /* 软复位 */ phy_write(PHY_REG_CONTROL, reg); /* 等待协商完成 */ uint32_t timeout = 10000; while (timeout--) { uint16_t status = phy_read(PHY_REG_STATUS); if ((status & (1 << 5)) && (status & (1 << 2))) /* 链接up且协商完成 */ break; rt_thread_mdelay(1); } }

这里要注意PHY寄存器位定义各厂商不统一,比如LAN8720和YT8512的寄存器0x1位定义就有差异。确定PHY后,一定要对着数据手册核对。

3.5 第四步:把驱动挂到RT-Thread上

底层函数写完,接下来就是做RT-Thread的适配层。通过eth_device结构体注册,流程大概是:

static struct eth_device enet_dev; static rt_err_t eth_init(rt_device_t dev) { enet_mac_config(); enet_desc_init(); phy_init(); return RT_EOK; } static rt_err_t eth_open(rt_device_t dev, rt_uint16_t oflag) { enet_dma_enable(); return RT_EOK; } static rt_err_t eth_close(rt_device_t dev) { enet_dma_disable(); return RT_EOK; } static rt_size_t eth_tx(rt_device_t dev, const void *buf, rt_size_t len) { rt_uint32_t i; struct enet_desc *desc = tx_desc + tx_index; memcpy((void *)desc->buffer_addr, buf, len); desc->control = ENET_TX_DESC_BUFFER_SIZE(len) | ENET_TX_DESC_OWNED_BY_DMA; /* 触发DMA发送 */ enet_dma_transmit_trigger(); /* 等待发送完成,这里可以加超时 */ while (!(desc->status & ENET_TX_DESC_OWNED_BY_CPU)) ; tx_index = (tx_index + 1) % ENET_TX_DESC_NUM; return len; } static rt_size_t eth_rx(rt_device_t dev, void *buf, rt_size_t len) { struct enet_desc *desc = rx_desc + rx_index; /* 如果描述符还归DMA所有,说明没有新到数据 */ if (desc->status & ENET_RX_DESC_OWNED_BY_DMA) return 0; uint32_t pkt_len = desc->status & ENET_RX_DESC_FRAME_LEN_MASK; memcpy(buf, (void *)desc->buffer_addr, pkt_len); /* 将描述符还给DMA */ desc->status = ENET_RX_DESC_OWNED_BY_DMA; rx_index = (rx_index + 1) % ENET_RX_DESC_NUM; return pkt_len; }

注册部分类似这样:

eth_device_init(&enet_dev, "e0"); enet_dev.parent.ops->init = eth_init; enet_dev.parent.ops->open = eth_open; enet_dev.parent.ops->close = eth_close; enet_dev.parent.ops->read = eth_rx; enet_dev.parent.ops->write = eth_tx; rt_device_control(&enet_dev.parent, NIOCTL_GADDR, mac_addr); eth_device_ready(&enet_dev);

需要特别说明的是,上面的实现是“一个描述符一个等待轮询”的简化版本,实际工控环境中如果丢包频繁,最好改成中断加信号量通知的机制。RT-Thread的eth_device驱动通常在接收中断里调用eth_device_ready(&enet_dev),通知lwIP线程来调用read函数取包。

3.6 第五步:中断处理与接收通知

以太网DMA支持多种中断,比如接收完成、发送完成、接收缓冲区溢出、总线错误。我实际用到的主要是接收完成中断和发送完成中断。

void ENET_IRQHandler(void) { uint32_t status = enet_dma_status_get(); if (status & ENET_DMA_STATUS_RB) /* 接收缓冲可读 */ { eth_device_ready(&enet_dev); } if (status & ENET_DMA_STATUS_TB) /* 发送完成 */ { /* 这里可以释放信号量,通知发送线程 */ } enet_dma_status_clear(status); }

这里我又要强调cache问题:接收中断触发后,lwIP线程调用read函数把数据从rx_buffer拷贝出去。但rx_buffer是DMA写的,CPU从D-Cache里读出来很可能是旧数据。正确做法是在read之前对rx_buffer做invalidate。有的底层实现把描述符和缓冲区的内存配置成不带cache的,简化问题,但代价是性能。具体怎么取舍,看你的工控项目对实时性的要求。

4. 常见问题与排查实录

4.1 网线插上后状态始终是down

这个现象我遇到得最多。首先怀疑PHY的复位问题,PHY复位时间不够,寄存器和状态都没准备好。建议先读PHY的ID寄存器,能读到预期值说明MDIO通,PHY基本活着。读不到,先查MDIO引脚复用和PHY地址。

RMII时钟缺失也能导致网络起不来。有的设计里REF_CLK由外部晶振提供,晶振没焊好或者没起振,整个PHY就无法工作。我排查时会用一个简单方法:把板子放在耳边听,有时晶振振会有微弱声音,更多时候还是用示波器测PHY的REF_CLK引脚,确认有没有50MHz方波。

4.2 能ping通但丢包严重

能ping通说明协议栈和驱动主路径是通的,丢包原因通常在以下几点:

  • 接收描述符太少,流量稍大就溢出,DMA没有空闲缓冲区,硬件丢弃新包。
  • RX缓冲区太小,大于缓冲区超长帧被截断或丢弃。10/100M以太网最大帧1518字节,我把接收缓冲区设成128字节,必须开启链式描述符或者用完整缓冲区,否则大包根本收不下。
  • Cache不一致,描述符状态更新了但CPU看到一个旧值,导致处理了重复数据包或错误忽略新包。
  • MTU和缓冲区配置不匹配,TCP层分片与驱动缓冲区大小不对。

处理思路:先把接收描述符加到32个,缓冲区大小调到最大以太网帧1536字节,然后关掉D-Cache做对照实验。如果关cache后正常,那基本就是cache一致性问题,再去用clean/invalidate解决,而不是一直关cache。

4.3 收包一段时间后死机

多半是描述符环被破坏或者内存越界。排查时最有效的手段是看DMA的中断状态寄存器,里面会有总线错误、描述符错误等标志。如果打印出的DMA错误位指向描述符不可用,八成是某个描述符的next地址不对,或者描述符在内存中被其他任务意外踩掉。

还有一种隐蔽情况:RT-Thread线程栈分配太小,协议栈处理时栈溢出,破坏了相邻内存里的描述符。这种情况下建议先开RT-Thread的栈溢出检查,再观察是不是收发大包时死机。

4.4 实测调试工具与流程

我调试enet驱动时,常用的工具顺序是:

第一步,串口打印PHY寄存器值,确认MDIO链路通不通,PHY型号对不对,协商速度和双工是否匹配。

第二步,在lwIP配置里打开调试宏,比如RT_LWIP_TCP、RT_LWIP_ETH,查看协议栈收到的包数量,确认驱动上送给协议栈的数据是否正常。

第三步,用PC的Wireshark抓包,分析板子发送的包格式是否正确,MAC地址、IP地址是否配置正确。

第四步,如果板上能跑shell,用ifconfig命令看e0的up/down状态,用ping命令测通断。

排查顺序严格从底层到上层:物理链路(link状态)→ MAC寄存器(计数)→ 驱动收发(中断、描述符)→ 协议栈(lwIP调试)→ 应用。我见过很多人一丢包就怀疑协议栈,结果问题出在物理层,白白浪费很多时间。

5. 性能优化与后续扩展思路

5.1 中断均衡与CPU占用率

驱动跑通只是第一步,工控项目里CPU占用率直接影响其他任务的实时性。如果进入中断的频率太高,比如每收一个小包就触发一次中断,CPU大部分时间都在进出中断,RT-Thread调度会受影响。

GD32H759的enet DMA支持中断合并/节流功能,可以把一组接收完成中断合并成一次,再通知lwIP。代价是延迟变高。工控场景里,Modbus TCP这类协议通常强调响应时间而不是吞吐量,所以中断合并幅度要适度。

另一个思路是收包流程中不用lwIP线程的workqueue,而是在中断上下文直接用信号量唤醒专用网络线程。在RT-Thread里,实际通过eth_device_ready触发接收回调,链路调度比较稳定。

5.2 从裸机思维转到RTOS驱动思维

最后我想强调一个思维转变:写裸机驱动时,一切代码都是在一个循环里跑,收发过程天然串行。到了RT-Thread,驱动是给内核服务的,收发操作会在不同线程上下文被调用,中断处理、线程调度、锁竞争都成了新问题。

刚开始移植时我把所有状态都放到全局变量里,想着简单,结果几个线程同时操作描述符,出现竞态,丢包和死循环都出现了。后来把所有描述符操作都加锁,确保同一时刻只有一个线程在操作描述符环,问题才解决。

具体到性能指标,我在这个项目上实测过:100M速率、TCP收发压力测试下,GD32H759跑RT-Thread加lwIP,CPU占用率可以控制在20%以内,丢包率为0(在小包长时段测试),对整个工控采集任务毫无影响。这个结果说明选型策略和驱动实现是可行的。

5.3 下一步可以怎么扩展

驱动稳定后,就可以在这个基础上做工业协议了。比如Modbus TCP从站,RT-Thread社区有FreeModbus移植案例;MQTT上报,用RT-Thread的webclient或mbedTLS加密通道;如果对接PLC和上位机,还可以研究PROFINET、EtherNet/IP这类工业实时协议。

我也在考虑做一套双网口冗余方案:GD32H759本身只带一个MAC,但结合外部PHY和交换机芯片,可以实现主备链路切换。在电力、轨道交通这类对可靠性要求高的场景,链路冗余是刚需。这部分后面有空再单独写一篇,先把enet驱动这个地基讲透,大家有什么问题欢迎留言交流。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/15 21:53:23

设计团队文件存储方案:三类场景下的NAS与云盘选型指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/15 21:53:22

FckSignups 项目概览:200+ 浏览器免登录工具的终极收藏

FckSignups 项目概览&#xff1a;200 浏览器免登录工具的终极收藏 【免费下载链接】FckSignups A list of tools that are open-source, in-browser, and require no-signups! 项目地址: https://gitcode.com/GitHub_Trending/fc/FckSignups FckSignups&#xff08;现已…

作者头像 李华
网站建设 2026/9/15 21:52:18

AI生成测试用例实战:从PRD解析到自动化脚本的提示词工程

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/15 21:47:59

C++和标准库速成(七)——类、作用域解析、统一初始化和指派初始化

目录1. 类1.1 定义类1.2 使用类2. 作用域解析3. 统一初始化(高度建议)4. 指派初始化参考1. 类 1.1 定义类 类定义了对象的特征。在C中&#xff0c;类通常在模块接口文件中定义和被导出&#xff0c;然而类的方法定义既可以在相同的模块接口文件中&#xff0c;也可以在对应的模块…

作者头像 李华
网站建设 2026/9/15 21:47:20

MoneyPrinterTurbo:免费AI短视频一键生成指南

MoneyPrinterTurbo&#xff1a;免费AI短视频一键生成指南 【免费下载链接】MoneyPrinterTurbo 利用 AI 大模型和自动化工作流&#xff0c;根据主题或关键词一键生成高清短视频。Generate HD short videos from a topic or keyword with an automated AI workflow. 项目地址: …

作者头像 李华
网站建设 2026/9/15 21:46:50

扫码模块二次开发:USB/TTL/RS232接口本质与实战避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华