Microcontroller programming differs from general-purpose "PC" programming by several facts:
- working with peripherals is a basic requirement, basic understanding of hardware1 and its control through registers is required
- memories are small and placement of things into various memories matters
- timing matters
- interrupts are a thing, and they require to understand atomicity issues
- multitasking is a thing (interrupts are a specific form of multitasking bound with hardware), and RTOS are not the silver bullet they promise to be (read: they generally replace the inherent complexity of multitasking by a different form of the same complexity)
After learning that, start programming microcontrollers with setting up a programming environment - editor, compiler, tools to download binary into the mcu and debugging.
These usually come bundled in some IDE, either provided by the mcu manufacturer as part of promotion of the mcu family, usually for free; or as a free/open source project (e.g. Eclipse with various mcu-specific additions), or from some of the commercial tool providers such as IAR, Raisonance, Keil, Rowley (these tend to be bought up by mcu manufacturers to be used as their own tool, this was the fate of Atollic or Tasking; Keil is owned by ARM).
A collection of non-bundled tools (e.g. any decent programming editor, gcc (+make), gdb (+openOCD)) is viable, too, and provides more freedom and less clutter;
but it's not that easy to set up without a decent guideline (and I am not aware of such at the moment for example here;
also a project template for STM32WL here).
The mcu manufacturers are not incentivized in supporting or promoting this path, as the IDEs they provide for free help to lock users in to their products.
Then read the available materials - datasheets, user/reference manuals2. In these, usually, the first few pages/chapters are generic and more important to read; others tend to be specific for some aspect of the mcu (some specific peripheral), skim those. Try to research on the net.
Try to look around for examples3. Usually there are manufacturer-provided examples, sometimes with accompanying documentation/application notes; but they are not necessarily written in a style which suits you. Try to look at diversity of examples to establish which style fits you the best.
Then write the "Hello, world" of the embedded world: a blinky. For that, you need to learn how to enable the peripheral controlling the IO pins, how to program it to set a pin to output and set it to low and high. For timing of blinking, resort to the basic time‑wasting loopdelay:
#define LOOP_DELAY_CONSTANT 1000000 void LoopDelay(int n) { volatile int i; for (i = 0; i < n; i++){}; } int main(void) { SetPinToOutput(); while(1) { SetPinToHigh(); LoopDelay(LOOP_DELAY_CONSTANT); SetPinToLow(); LoopDelay(LOOP_DELAY_CONSTANT); } }
Note, that delay must be after both setting pin to high and to low. If there is no blinking (which may also mean that blinking is so fast that the eye perceives it as a continuous light), try to increase/decrease LOOP_DELAY_CONSTANT by one or several orders of magnitude. At that point, you can ignore any complex clock setup, any reset-default clock will do.
Then experiment with trying features of individual peripherals. Be prepared to write dozens of small experimental programs, learn how to start/clone a new small project quickly.
Generally, read the chapter of peripheral you want to work with, in the Reference Manual, so that you know what are you doing. Read the description of registers of given peripheral for understanding the details.
Then write code which changes content of given peripheral registers. Many registers contain several individual bit-fields (or individual bits) to set a certain feature, so you may want to change them individually, without affecting the rest of the register.
This is generally accomplished in a sequence steps, which read-modify-write the register (even if it is written as a single C operation like |= or &=). You can do that with implicit knowledge of previous state of given register, or without that. For example, if you are writing code for initial set up of a peripheral, you can rely on reset values of registers:
GPIOA->MODER |= (0b01 << (2 * 6)); // set PA6 to Output by setting its field to 0b01, reset value of this field is 0b00
but if this code is to be used also afterwards when registers have already been set, you cannot make the "reset values" assumption:
GPIOA->MODER = (GPIOA->MODER & ~(0b11 << (2 * 6))) // first clear field for PA6 to 0b00 | (0b01 << (2 * 6)) // then set it to 0b01 ;
For effectiveness, if you want to change several fields of a single register at one place of program, don't write consecutive operations to the same register, but merge them (note that constant expressions are folded by the compiler at compile time):
GPIOA->MODER = (GPIOA->MODER & ~(0 // clear all fields | (0b11 << (2 * 4)) // for PA4 | (0b11 << (2 * 5)) // and for PA5 | (0b11 << (2 * 6)) // and for PA6 | (0b11 << (2 * 7)) // and for PA7 )) | (0 | (0b00 << (2 * 4)) // set PA4 to Input | (0b11 << (2 * 5)) // set PA5 to Analog | (0b01 << (2 * 6)) // set PA6 to Output | (0b10 << (2 * 7)) // set PA7 to Alternative Function );
In the vast majority of cases, you know what the outcome of operation has to be, so you can avoid read-modify-write (RMW) by writing a direct value to the register:
GPIOA->MODER = 0 | (0b11 << (2 * 5)) // set PA5 to Analog | (0b01 << (2 * 6)) // set PA6 to Output | (0b10 << (2 * 7)) // set PA7 to Alternative Function // both PA13 and PA14 have to be set as AF so that SWD keeps working | (0b10 << (2 * 13)) | (0b10 << (2 * 14)) // all other GPIOA pins are set to 0b00, i.e. as Input ;
In all above examples, there may be symbols defined in device headers which help to avoid "magic numbers" (e.g. instead of (0b11 << (2 * 6)) in the clearing portion you should write GPIO_MODER_MODE6; instead of (xxx << (2 * 6)) in the setting portion you should write (xx << GPIO_MODER_MODE6_Pos) ); in cases where device headers don't contain appropriate symbols (which for STM32 is the case for value of multiple-bit bitfields), user can/should define such appropriate symbols:
// GPIOx_MODER - 2 bits per pin #define GPIO_Mode_In 0x00 // GPIO Input Mode #define GPIO_Mode_Out 0x01 // GPIO Output Mode #define GPIO_Mode_AlternateFunction 0x02 // GPIO Alternate function Mode #define GPIO_Mode_AF GPIO_Mode_AlternateFunction #define GPIO_Mode_Analog 0x03 // GPIO Analog Mode GPIOA->MODER = 0 | (GPIO_Mode_Analog << GPIO_MODER_MODE5_Pos) // set PA5 to Analog | (GPIO_Mode_Out << GPIO_MODER_MODE6_Pos) // set PA6 to Output | (GPIO_Mode_AF << GPIO_MODER_MODE7_Pos) // set PA7 to Alternative Function // both PA13 and PA14 have to be set as AF so that SWD keeps working | (GPIO_Mode_AF << GPIO_MODER_MODE13_Pos) | (GPIO_Mode_AF << GPIO_MODER_MODE14_Pos) // all other GPIOA pins are set to 0b00, i.e. as Input ;
Note, that generally there are three groups of registers: those, which are used to set up some feature, and they behave almost like a normal memory, upon reading they return what you have written to them so it is possible to perform read-modify-write operations on them, like described above.
The second group of registers are used to indicate hardware state, i.e. hardware changes them from "inside". Generally there are two methods how software "acknowledges" the change, i.e. "clears" them: either the status register is read-only, and then there is another register writing to which status is "acknowledged"; or writing to the same register "acknowledges" status. In the latter case, it's prescribed, whether writing 0 or 1 into given bit is required for "acknowledgement" and writing the other value (i.e. 1 or 0 respectively) then does nothing. In any case, thanks to these mechanisms, for this class of registers, RMW is not needed. Contrary, using RMW on these registers is outright harmful, as it may inadvertently clear an already unhandled status.
The third group of registers are used to trigger some hardware action, these are generally write-only and internally self-clearing. Some of them can be read back thus indicating internal progress of the action which was triggered, so RMW is again not to be used on them to avoid inadvertent re-triggering.
In some designs, bits/fields of all three groups are mixed together within one register. There are also variants to the status-clear scheme out there, e.g. with "toggling" bit values upon writing a certain value (e.g. in the device-only USB module in STM32); these are rare and really tricky to use. In any case, careful reading of the manual and careful thinking on any action for such registers is necessary.
Followup (work in progress): 101.01 Ring Buffers, 101.02 Interrupt Service Routines, 101½ Minimalistic Toolchain .
You may also go the path of clicking in the code in any configurator, again provided as convenience/lock by mcu manufacturers, and then trying to build upon that. You may use various "peripheral libraries" and "hardware abstraction layers" out there. And you may also use frameworks such as Arduino, which isolates you from the "nasty details of hardware". All these methods provide a relatively easy and painless way to accomplish certain groups of tasks. However, they inevitably implement only a limited fraction of the mcu capabilities and cater for a given group of applications, covering whatever their creators deemed to be the "usual cases". As soon as anything out-of-ordinary is to be achieved, these tools tend to become more of an obstacle than help, and usually time/effort for learning the "nasty details" spared at the start are spent usually in a more painful way, to learn not only the underlying details, but also to learn the various couplings between those details and the libraries/frameworks used.
1. This includes both the internal circuits/modules of the mcu, and externally connected hardware. Hardware and software in mcu are intimately interconnected. Basic knowledge of electronics is necessary. Reading of datasheets and other materials of connected external peripherals is inevitable. Debugging (even if we speak of software) involves active usage of electric measurement devices, including basic multimeters/continuity testers, logic analyzers (LA), oscilloscopes.
2. These are usually provided by mcu manufacturer in the given mcu's web product folder. For STM32, elements of documentation are described here.
3. Maybe strangely, software examples were perhaps for no mcu/mcu family provided by manufacturer as straighforwardly as documentation. Usually, they come either as an attachment to application note, or as example code for a devboard, or recently as part of various "accessor libraries". This is also case of STM32, where example code comes as part of the Cube bundles (by whih "libraries" for individual families is meant, e.g. CubeF4 for the 'F4 family; these are nowadays also called "firmware" within the CubeIDE environment), and also there they represent examples for the Nucleo/Disco/Eval type of devboards. For the STM32F0xx and STM32L0xx families, ST provided code examples directly in the respective Reference Manuals as Appendix A (and these examples had been elaborated within the Snippets initiative, having been already discontinued by ST); but generally ST refuses to provide basic register-based examples.