[post] 使用 gcc 开发 STM32

📅 2024-09-04

🔄 2024-09-04

⌚ Reading time: 4 min


使用 gcc 开发 STM32

(2024.09.04 创建,2026.01.23 完善。)

在 STM32 开发领域,Keil、IAR 这样的 IDE 几乎是默认选择。

在 Ubuntu 环境下,直接使用 arm-none-eabi-gcc 来开发 STM32,看起来多少有点“吃力不讨好”。

但真正掌握这套流程的意义,远不止是“不用 IDE 写单片机程序”。

用 GCC 裸机开发这件事背后隐含着一个非常重要的能力:

拿到一颗全新的、基于 ARM Cortex-M 内核的芯片,只有芯片手册,甚至连官方 IDE 都还没准备好的时候,你依然能把它跑起来。只要掌握了

  • C 语言
  • 一点点 ARM 汇编(只会看也行)
  • 交叉编译工具链
  • 链接脚本

那么芯片是否“被官方支持”并不重要。

更现实的一层意义,很多大型“裸机”项目比如 u-boot,甚至 linux kernel,各类 SoC BSP,这些项目开发、调试并不依赖图形化工具。而 STM32 裸机开发,恰好是理解这一整套工具链的最小项目。从一颗 Cortex-M3 芯片开始,去理解“代码是如何真正跑到 CPU 上的”。

我的环境

  • Host:ubuntu 22.04
  • MCU:STM32F103C8T6 64K flash 20K ram
  • 调试器:st-link v2
  • 工具链:13.3.rel1-x86_64-arm-none-eabi

从最简单的程序

int main()
{
    return 0;
}

这是一个合法的 C 程序,但它还不是一个能在 MCU 上运行的程序。CPU 上电以后,并不知道什么是 main()

上电之后,以 cortex-m3 为例,上电或复位后,从 0x0000_0000 ( 一般是从 0x0800_0000 内部 flash 映射过去的)取 4Byte 到 MSP,然后取再往后取 4Byte 到 PC。即跳转 Reset Handler,这是硬件与程序员的约定,所有的 CPU 都有类似的约定。

为了让 main 函数能在 CPU 上执行,还需要做到芯片通电后从 Reset Handler 跳转到 main 入口。

一段最小启动代码(Reset Handler)

__attribute__((naked, noreturn)) void _reset(void) 
{
    extern long _sbss, _ebss, _sdata, _edata, _sidata;

    // 清零 .bss
    for (long *dst = &_sbss; dst < &_ebss; dst++) 
      *dst = 0;

    // 初始化 .data
    for (long *dst = &_sdata, *src = &_sidata; dst < &_edata;) 
      *dst++ = *src++;

    main();             // Call main()

    for (;;) (void) 0;  // Infinite loop in the case if main() returns
}

这段代码建立 C 运行时环境,初始化全局变量,调用 main。

本质上就是 crt0 的最小实现。

此外还需要一个 0x0000_0000 处的布局

extern void _estack(void);

__attribute__((section(".vectors"))) void (*const tab[16 + 60])(void) = {
    _estack,  // 初始 SP
    _reset    // Reset Handler
};

链接脚本是内存布局的最终裁决者,有好多变量都用了 extern ,这些变量在链接脚本内,链接时会计算然后填入。此外有好多修饰也是给连接器看的。

ENTRY(_reset);
MEMORY {
  flash(rx)  : ORIGIN = 0x08000000, LENGTH = 64k
  sram(rwx) : ORIGIN = 0x20000000, LENGTH = 20k  /* remaining 64k in a separate address space */
}
_estack     = ORIGIN(sram) + LENGTH(sram);    /* stack points to end of SRAM */

SECTIONS {
  .vectors  : { KEEP(*(.vectors)) }   > flash
  .text     : { *(.text*) }           > flash
  .rodata   : { *(.rodata*) }         > flash

  .data : {
    _sdata = .;   /* .data section start */
    *(.first_data)
    *(.data SORT(.data.*))
    _edata = .;  /* .data section end */
  } > sram AT > flash
  _sidata = LOADADDR(.data);

  .bss : {
    _sbss = .;              /* .bss section start */
    *(.bss SORT(.bss.*) COMMON)
    _ebss = .;              /* .bss section end */
  } > sram

  . = ALIGN(8);
  _end = .;     /* for cmsis_gcc.h  */
}

链接脚本控制了代码存放的位置。解决了三个问题

  • 代码放在哪
  • 数据放在哪
  • CPU 如何找到入口

最后,如何构建的问题,如何生成可执行文件。写一个 makefile

CFLAGS  ?=  -W -Wall -Wextra -Werror -Wundef -Wshadow -Wdouble-promotion \
            -Wformat-truncation -fno-common -Wconversion \
            -g3 -Os -ffunction-sections -fdata-sections -I. \
            -mcpu=cortex-m3 -mthumb $(EXTRA_CFLAGS)
LDFLAGS ?= -Tlink.ld -nostartfiles -nostdlib --specs nano.specs -lc -lgcc -Wl,--gc-sections -Wl,-Map=$@.map
SOURCES = main.c 

ifeq ($(OS),Windows_NT)
  RM = cmd /C del /Q /F
else
  RM = rm -f
endif

build: firmware.bin

firmware.elf: $(SOURCES)
	arm-none-eabi-gcc $(SOURCES) $(CFLAGS) $(LDFLAGS) -o $@

firmware.bin: firmware.elf
	arm-none-eabi-objcopy -O binary $< $@

flash: firmware.bin
	st-flash --reset write $< 0x8000000

clean:
	$(RM) firmware.*

语法倒不是重点,可以让 GPT 帮忙写,主要是思路,所有的 gcc 工具都可以使用,如 readelf、objdump,gdb 。

下载和调试

下载使用 st-link 工具包。这个工具包除了 st-flash 工具还有一个 st-util 工具,这个工具可以启动一个 gdb server 在 4242 端口。然后就可以使用 gdb 工具来调试 STM32 了。

上面的代码下载进去以后是没有任何现象的。想要看看代码是不是真的运行起来了,需要用到调试工具。

load 加载被调试的程序,然后剩下的操作就是 gdb 的操作。从这一刻起,用的已经是和 Linux 内核开发同一套调试方式。

点亮 LED:真正和硬件对话

到了 C 语言的领域,那么几乎就可以做任何 c 语言能做到的事情了。先点个灯。点灯要做的是开时钟,设置 GPIO 模式,然后控制引脚寄存器。对着手册去写

struct rcc {
  volatile uint32_t CR, CFGR, CIR, APB2RSTR, APB1RSTR, AHBENR, 
                    APB2ENR, APB1ENR, BDCR, CSR;
};

struct gpio {
  volatile uint32_t CRL, CRH, IDR, ODR, BSRR, BRR, LCKR;
};

static inline void spin(volatile uint32_t count) {
  while (count--) asm("nop");
}

static inline void gpio_write(uint8_t val) {

    if (val == 1)
        GPIOC->BSRR = 0x20 << 8;
    else
        GPIOC->BSRR = 0x20 << 24;
}

int main(void) {

    //寄存器映射,是所有外设驱动的起点。
    struct rcc* rcc = (void *)0x40021000;

    struct gpioc* gpioc = (void *)0x40011000;

    rcc->APB2ENR |= (1<<4);

    gpioc->CRH &= ~(0x0fU << 20);         // Clear existing setting
    gpioc->CRH |= 0x20 << 16;       // Set new mode 

  for (;;) {
    gpio_write(1);
    spin(999999);
    gpio_write(0);
    spin(999999);
  }
  return 0;
}

功能是可以实现的,但是可读性也是几乎没有的。功能很简单,但这已经是所有 HAL / CMSIS 的原型。原理上是这些原理,真正的想用起来,还有非常多的细节要去考虑。

原理就是这些原理。真正工程化以后,细节会非常多,但不会再神秘。

rt-thread 开发

2026.01.23

ROS2 提供了 micro-ros,但是是以 .a 和一个 .h 的形式,并且要求项目对 cmake 的支持。这里不聊 cmake。

那么还是在 ubuntu 环境下可能会更顺手一些,正好 rt-thread 提供了一些基础设施。

接续裸机开发的思路,开始真正的做一些事情。

ubuntu 下 rt-thread 环境

安装

# 工具链准备
wget https://developer.arm.com/-/media/files/downloads/gnu/11.2-2022.02/binrel/gcc-arm-11.2-2022.02-x86_64-arm-none-eabi.tar.xz

tar xvf gcc-arm-11.2-2022.02-x86_64-arm-none-eabi.tar.xz
sudo mv gcc-arm-11.2-2022.02-x86_64-arm-none-eabi /opt/
rm gcc-arm-11.2-2022.02-x86_64-arm-none-eabi.tar.xz

# rt-thread 需要的工具
sudo apt install -y scons
sudo apt install -y pip

pip install kconfiglib
pip install tqdm
pip install scons==4.5.2

echo "source ~/.env/env.sh" >> ~/.bashrc

打开一个 rtt 的 bsp,scons –meunconfig 之后会自动 clone .env,

source ~/.env/env.sh

之后可以使用 pkgs 相关工具

stm32 的 HAL 库作为软件包,需要 pkgs –update 去拉一下

然后就可以 scons –target=cmake

可以手动在 rtconfig.py 写死,或者用环境变量指定

export RTT_CC=gcc
export RTT_EXEC_PATH=/opt/gcc-arm-11.2-2022.02-x86_64-arm-none-eabi/bin

然后就可以编译了

mkdir build-cmake

cd build-cmake

cmake ..

make -j

关于下载和调试。

使用 jlink

嵌入式系统开发

大型项目构建与管理中的一些工具,思路,方法

scons menuconfig与宏定义开关

makefile

Sconscript

SConstruct