第四章:裸机编程基础——寄存器直接操作、GPIO控制LED闪烁、UART串口打印Hello World

好,咱们进入正题。这一章是嵌入式开发的「起手式」。说白了,就是让芯片听你的话,让它亮灯就亮灯,让它说话就说话。

很多初学者一上来就怼操作系统、怼复杂框架,结果连个LED都点不亮。我个人习惯是,拿到一块新芯片,第一件事就是写裸机程序。为什么?因为只有裸机才能让你真正理解芯片的「脾气」。

4.1 寄存器直接操作——芯片的「开关面板」

芯片内部其实就是一个巨大的开关矩阵。每个开关叫「寄存器」,你拨动开关,芯片就干活。

拿三星的Exynos系列来说,它的GPIO控制器有一堆寄存器。比如控制方向的寄存器叫GPIOx_CON,控制输出电平的叫GPIOx_DAT。你往这些寄存器里写值,就等于在拨开关。

举个例子,假设我们要控制GPIOA的第5号引脚:

// 定义寄存器地址(以Exynos4412为例)
#define GPIOA_BASE      0x11400000
#define GPIOA_CON       (*(volatile unsigned int *)(GPIOA_BASE + 0x00))
#define GPIOA_DAT       (*(volatile unsigned int *)(GPIOA_BASE + 0x04))

// 设置第5脚为输出模式(假设每4位控制一个引脚)
GPIOA_CON = (GPIOA_CON & ~(0xF << 20)) | (0x1 << 20);

// 输出高电平
GPIOA_DAT |= (1 << 5);

这里有个关键点:volatile关键字。我刚开始学的时候漏写过,结果编译器优化后,寄存器操作全被跳过了。嗯,那LED死活不亮,查了半天才发现是这个问题。

警告: 操作寄存器时,一定要用 volatile 修饰。否则编译器可能把寄存器访问优化掉,导致硬件不响应。

4.2 GPIO控制LED闪烁——让芯片「眨眼」

LED闪烁是嵌入式界的「Hello World」。别看简单,里面门道不少。

硬件连接:LED正极接GPIO引脚,负极通过电阻接地。这样引脚输出高电平时LED亮,低电平时灭。

软件实现:

void delay(volatile unsigned int count) {
    while(count--);
}

void led_blink(void) {
    // 初始化:设置GPIO为输出
    GPIOA_CON = (GPIOA_CON & ~(0xF << 20)) | (0x1 << 20);
    
    while(1) {
        GPIOA_DAT |= (1 << 5);   // 亮
        delay(0x100000);           // 延时
        GPIOA_DAT &= ~(1 << 5);  // 灭
        delay(0x100000);           // 延时
    }
}

这里有个坑:延时函数不能太精确。为什么?因为裸机没有定时器中断,纯靠CPU空转。我在项目中遇到过,用这种延时做呼吸灯效果,结果不同芯片频率不同,效果完全不一样。后来改用硬件定时器才解决。

小技巧: 调试LED闪烁时,可以用示波器看引脚波形。如果没有示波器,用逻辑分析仪也行。我习惯用逻辑分析仪抓波形,一眼就能看出延时是否准确。

4.3 UART串口打印Hello World——让芯片「说话」

LED闪烁只能告诉你「芯片活着」,但没法告诉你「芯片在想什么」。这时候就需要串口了。

UART通信说白了就是两根线:TX(发送)和RX(接收)。波特率要一致,数据格式要匹配。我见过最坑的事,就是两个人调试串口,一个设9600,一个设115200,结果互相看不懂对方在说什么。

三星芯片的UART寄存器配置:

// UART寄存器定义(以Exynos4412 UART0为例)
#define UART0_BASE      0x13800000
#define ULCON0          (*(volatile unsigned int *)(UART0_BASE + 0x00))
#define UCON0           (*(volatile unsigned int *)(UART0_BASE + 0x04))
#define UTRSTAT0        (*(volatile unsigned int *)(UART0_BASE + 0x10))
#define UTXH0           (*(volatile unsigned int *)(UART0_BASE + 0x20))
#define URXH0           (*(volatile unsigned int *)(UART0_BASE + 0x24))
#define UBRDIV0         (*(volatile unsigned int *)(UART0_BASE + 0x28))

void uart_init(void) {
    // 设置波特率:115200 @ 50MHz时钟
    UBRDIV0 = 27;  // 计算值:50MHz / (16 * 115200) - 1 ≈ 26.1,取整27
    
    // 配置:8位数据,1位停止位,无校验
    ULCON0 = 0x03;
    
    // 配置:使能发送和接收,使用中断模式
    UCON0 = 0x05;
}

void uart_putc(char c) {
    // 等待发送缓冲区空
    while(!(UTRSTAT0 & 0x02));
    UTXH0 = c;
}

void uart_puts(const char *str) {
    while(*str) {
        uart_putc(*str++);
    }
}

// 主函数
void main(void) {
    uart_init();
    uart_puts("Hello World!\r\n");
    
    while(1) {
        // 循环等待
    }
}

这里要注意波特率计算。公式是:UBRDIV = (时钟频率 / (16 × 目标波特率)) - 1。我刚开始做的时候,算出来的值直接取整,结果串口数据全是乱码。后来才发现要四舍五入,而且还要考虑时钟误差。

核心要点:

  • 寄存器操作是裸机编程的基础,必须理解地址映射和位操作
  • GPIO控制LED是验证硬件是否正常的最快方法
  • UART串口是嵌入式调试的「眼睛」,学会它等于学会和芯片对话

4.4 实战经验总结

我做了这么多年嵌入式,总结出几条铁律:

  1. 先看数据手册:别急着写代码。把寄存器地址、位定义搞清楚,能省一半调试时间。
  2. 从简单开始:先点灯,再串口,最后才上复杂外设。一步一个脚印。
  3. 善用调试工具:示波器、逻辑分析仪、串口助手,这些工具比你的眼睛靠谱得多。
  4. 注意代码可读性:用宏定义代替魔法数字,用函数封装重复操作。不然三个月后你自己都看不懂写的什么。

我曾经接手过一个项目,前任工程师把所有寄存器操作都写在main函数里,整整2000行。我花了三天才理清楚每个位是干什么的。从那以后,我坚持用结构体封装寄存器组,用宏定义位掩码。代码清晰了,bug也少了。

好了,这一章就到这里。下一章我们会讲中断和定时器,到时候LED闪烁就不用傻傻的延时函数了。