硬件基础:SoC时钟树架构与常见外设总线

做嵌入式Linux驱动,说白了就是在跟两样东西打交道:时钟和总线。时钟是SoC的心跳,总线是外设的血管。这两样搞不明白,驱动写出来大概率是跑不起来的。

我记得刚入行那会儿,接手一个音频驱动的活儿。板子上的codec死活不出声,我查了三天寄存器,最后发现是I2C总线的时钟没配好——SCL波形都变形了。从那以后,我养成了一个习惯:拿到一块新板子,第一件事就是看时钟树。

2.1 SoC时钟树:一颗芯片的“心跳”网络

什么叫时钟树?说白了,就是一颗SoC内部所有时钟信号的产生、分配、变换的完整路径。你想想看,CPU要一个频率,DDR要另一个频率,USB又要一个频率——总不能每个模块都自己挂个晶振吧?

所以SoC的做法是:用少量几个晶振(通常是24MHz或32.768kHz),通过PLL倍频出高频时钟,再经过分频器、门控单元、多路选择器,分发到各个模块。

核心要点:时钟树的设计目标就三个——低抖动、低功耗、灵活配置。驱动工程师最关心的是第三个:能不能在软件里动态改频率。

下面这张图是我自己画的一个典型SoC时钟树结构,你可以对照着看:

典型 SoC 时钟树架构 晶振 24MHz RTC 32.768kHz PLL1 (CPU) PLL2 (DDR) PLL3 (Periph) 分频 / 门控 分频 / 门控 分频 / 门控 CPU 集群 DDR 控制器 I2C / SPI / UART USB / Ethernet 图例说明: • 蓝色:时钟源(晶振 / OSC) • 红色:PLL 倍频单元(产生高频时钟) • 绿色:分频 / 门控单元(调整频率、关断时钟) • 紫色:最终消费时钟的模块(CPU / 外设 / 总线) • 箭头:时钟信号流向,从晶振 → PLL → 分频 → 模块
我的习惯:在Linux里看时钟树,最直接的办法是读设备树里的clocks属性和assigned-clock-rates。另外/sys/kernel/debug/clk/clk_summary这个文件能列出所有时钟的当前状态,调试时非常有用。

2.2 时钟管理的关键概念

做驱动开发,时钟管理这块有几个概念你必须烂熟于心:

概念 说明 驱动中的体现
时钟源 产生原始时钟信号的硬件单元(晶振、RC振荡器) clk_get() 获取时钟句柄
PLL 锁相环,将低频时钟倍频到高频 设备树中配置 assigned-clock-rates
分频器 将高频时钟降为模块需要的频率 clk_set_rate() 动态调整
门控 开关时钟信号,用于省电 clk_enable() / clk_disable()
时钟域 同一时钟源驱动的逻辑单元集合 跨时钟域同步(CDC)问题
我曾经踩过的坑:有一次我写一个SPI驱动,在probe函数里调了clk_prepare_enable(),但remove函数里只调了clk_disable(),忘了clk_unprepare()。结果模块卸载后,时钟门控没完全关掉,系统休眠时漏电严重。记住:prepare和enable是成对出现的,缺一不可。

2.3 三大外设总线:I2C、SPI、UART

搞嵌入式Linux,你迟早会跟这三条总线打交道。它们各有各的脾气,选对了事半功倍,选错了调试到哭。

2.3.1 I2C:两线制,多设备,慢但稳

I2C只有两根线:SCL(时钟)和SDA(数据)。所有设备挂在同一条总线上,靠地址区分。标准模式100kHz,快速模式400kHz,高速模式能到3.4MHz。

我个人习惯用I2C来挂那些配置类的外设——比如音频codec、传感器、触摸屏控制器。为什么?因为这类设备不需要高速数据传输,但要求连线少、支持多设备共享总线。

驱动开发要点:Linux的I2C子系统分为三部分——I2C核心层(协议无关)、I2C总线适配器驱动(硬件相关)、I2C设备驱动(你的外设驱动)。你写设备驱动时,只需要实现struct i2c_driver里的probe和remove,然后通过i2c_smbus_read/write系列函数读写寄存器。

举个例子,一个典型的I2C设备驱动probe函数长这样:

static int my_sensor_probe(struct i2c_client *client,
                           const struct i2c_device_id *id)
{
    struct my_sensor_data *data;
    int ret;

    data = devm_kzalloc(&client->dev, sizeof(*data), GFP_KERNEL);
    if (!data)
        return -ENOMEM;

    i2c_set_clientdata(client, data);

    /* 读取设备ID寄存器,确认硬件存在 */
    ret = i2c_smbus_read_byte_data(client, REG_DEVICE_ID);
    if (ret < 0) {
        dev_err(&client->dev, "failed to read device id\n");
        return ret;
    }

    dev_info(&client->dev, "sensor detected, id=0x%02x\n", ret);
    return 0;
}

2.3.2 SPI:四线制,全双工,快

SPI用四根线:SCLK(时钟)、MOSI(主出从入)、MISO(主入从出)、CS(片选)。全双工通信,速度轻松上几十MHz。

我一般在什么场景下用SPI?数据量大、实时性要求高的外设——比如LCD显示屏、ADC采样芯片、无线模块。SPI没有像I2C那样的地址概念,每个设备独占一条片选线,所以连线会多一些。

避坑指南:SPI有四种模式(CPOL和CPHA的组合),主设备和从设备必须配成一样的。我曾经帮一个同事调SPI显示屏驱动,折腾了两天,最后发现是设备树里spi-cpol和spi-cpha配反了。嗯,这种低级错误,犯过一次就记住了。

SPI驱动在Linux里的注册方式:

static const struct spi_device_id my_spi_ids[] = {
    { "my_adc", 0 },
    { }
};
MODULE_DEVICE_TABLE(spi, my_spi_ids);

static struct spi_driver my_spi_driver = {
    .driver = {
        .name = "my_adc",
        .of_match_table = my_spi_of_match,
    },
    .probe = my_spi_probe,
    .remove = my_spi_remove,
    .id_table = my_spi_ids,
};
module_spi_driver(my_spi_driver);

2.3.3 UART:异步串行,简单可靠

UART只需要两根数据线:TXD和RXD。没有时钟线,靠双方约定好的波特率来同步。常见的波特率有9600、115200、921600等。

UART最大的优点是简单。任何两个带UART的芯片,只要把TXD和RXD交叉一接,配好波特率,就能通信。我在项目中常用UART来做调试串口、GPS模块、蓝牙模块等。

注意:UART是异步通信,收发双方必须使用相同的波特率、数据位、停止位和校验位。如果通信出现乱码,八成是波特率没对上。我调试时习惯先用逻辑分析仪抓波形,量一下实际波特率,再跟驱动里配的值对比。

2.4 三种总线的对比与选型

为了让你看得更清楚,我把三条总线的关键参数列了个表:

特性 I2C SPI UART
信号线数 2(SCL + SDA) 4+(SCLK + MOSI + MISO + CS) 2(TXD + RXD)
通信方式 半双工 全双工 全双工
速度 100kHz ~ 3.4MHz 10MHz ~ 100MHz+ 最高约 1Mbps(典型)
多设备支持 是(地址寻址) 是(片选线) 否(点对点)
硬件流控 无 无 可选(RTS/CTS)
典型应用 传感器、codec、EEPROM LCD、ADC、无线模块 调试串口、GPS、蓝牙

选型时我的经验是:

  • 设备多、连线少 → 选I2C
  • 数据量大、速度快 → 选SPI
  • 简单可靠、点对点 → 选UART
最后说一句:时钟树和总线是SoC硬件的基础骨架。你花一周时间把这两块吃透,后面写驱动时会发现——很多问题其实不是驱动逻辑错了,而是时钟没配好,或者总线时序不对。基础打牢了,上层才能盖得稳。