You dont want to mess with an intel, no. Not a good starting path at all. Unless perhaps you run one of the 8086/88 simulators, but even then I would not start there.
As far as learning assembly goes, pdp11 (not joking), msp430, arm thumb, arm, and a few others are good starting points, build a good foundation then each subsequent instruction set is easier. You can start with simulations and that is not a bad path, there are pdp11 simulators (simh), msp430 there is an open core, armv2/3 open core plus countless mips and risc-v's I would do those later. You want something supported by gnu tools so all of the above (yes there is a maintainted pdp-11 backend on binutils and gcc). msp430 and countless arm based microcontrollers are available and those would be the best hardware place to start, that or a pi-zero (but not a full sized pi nor beagle bone black not yet). yes there are avr's, etc but can be harder to use depending on what one you get.
Realistically there are many mcu boards in the $10 to $20 range that would be a good starting point from a cost perspective, some may require another or few $2 - $20 boards. Going to pick one path first.
st is one of the leading companies with cortex-m (arm) based mcus that run in thumb mode which is one of the simpler arm instruction sets. The instruction set with the most compatibility across the whole of the arm based world.
Running on linux will make your life much much easier but not required, tools can be found for Windows or OSX.
A minimal program to add two numbers for a cortex-m would be:
.thumb
.word 0x20001000
.word reset
.thumb_func
reset:
mov r0,#1
mov r1,#2
add r2,r1,r0
b .
that is it, 100% of the software
arm-linux-gnueabi-as flash.s -o flash.o
arm-linux-gnueabi-ld -Ttext=0x08000000 flash.o -o flash.elf
arm-linux-gnueabi-objcopy -O binary flash.elf flash.bin
arm-linux-gnueabi-objdump -d flash.elf > flash.list
The code in this answer doesnt care between arm-none-eabi- and arm-linux-gnueabi- built gnu tools.
cat flash.list
Disassembly of section .text:
08000000 <_start>:
8000000: 20001000
8000004: 08000009
08000008 <reset>:
8000008: 2001 movs r0, #1
800000a: 2102 movs r1, #2
800000c: 180a adds r2, r1, r0
800000e: e7fe b.n 800000e <reset+0x6>
I am leading up to a particular board/chip/family the core actually looks for the vector table at 0x00000000 but this brand addresses the user flash at 0x08000000 then mirrors it to 0x00000000 if the chip is configured to boot from the user flash. The first word goes into the stack pointer and the second is the reset vector address orred with one (a legacy thumb thing, roll with it).
And then you can see one register is loaded with a 1, one with a 2 then they are added together and stored in a third.
I am starting with a NUCLEO-F446RE which is currently readily available from amazon, mouser, digikey, st, etc. Cheaper from places other than amazon but then you pay shipping. There are $10 nucleo boards that work just fine as well. Some of the nucleo boards dont hook the target mcu uart through the debug end of the board (another mcu), so you want to do your research first. If you get one of these boards, then you dont for now need to buy any other hardware this is it.
The board when plugged in shows up as a virtual thumb drive, and you simply copy the .bin file over in order to load the program into the flash.
cp flash.bin /media/user/NODE_F446RE/
Now the program is loaded the processor reset and the program is running.
Using another free tool, openocd I can connect to the debugger in the mcu:
openocd -f /usr/share/openocd/scripts/interface/stlink-v2-1.cfg -f /usr/share/openocd/scripts/target/stm32f4x.cfg
Open On-Chip Debugger 0.10.0
Licensed under GNU GPL v2
For bug reports, read
http://openocd.org/doc/doxygen/bugs.html
Info : auto-selecting first available session transport "hla_swd". To override use 'transport select <transport>'.
Info : The selected transport took over low-level target control. The results might differ compared to plain JTAG/SWD
adapter speed: 2000 kHz
adapter_nsrst_delay: 100
none separate
Info : Unable to match requested speed 2000 kHz, using 1800 kHz
Info : Unable to match requested speed 2000 kHz, using 1800 kHz
Info : clock speed 1800 kHz
Info : STLINK v2 JTAG v36 API v2 SWIM v26 VID 0x0483 PID 0x374B
Info : using stlink api v2
Info : Target voltage: 3.266535
Info : stm32f4x.cpu: hardware has 6 breakpoints, 4 watchpoints
in another window telnet into the openocd debugger, stop the core and dump the registers
telnet localhost 4444
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
Open On-Chip Debugger
> halt
target halted due to debug-request, current mode: Thread
xPSR: 0x01000000 pc: 0x0800000e msp: 0x20001000
> reg
===== arm v7m registers
(0) r0 (/32): 0x00000001
(1) r1 (/32): 0x00000002
(2) r2 (/32): 0x00000003
(3) r3 (/32): 0x00000000
(4) r4 (/32): 0x00000000
Can see that the three registers have the values we programmed.
As well as the vector table loaded stack pointer
(13) sp (/32): 0x20001000
If I want to do this in C and make it slightly more complicated, Ill go ahead and make a more complete project. 100% of the code:
flash.s
.cpu cortex-m0
.thumb
.thumb_func
.global _start
_start:
.word 0x20001000
.word reset
.thumb_func
reset:
bl main
b .
main.c
unsigned int fun ( unsigned int a, unsigned int b );
int main ( void )
{
return(fun(1,2));
}
fun.c
unsigned int fun ( unsigned int a, unsigned int b )
{
return(a+b);
}
flash.ld
MEMORY
{
rom : ORIGIN = 0x08000000, LENGTH = 0x1000
ram : ORIGIN = 0x20000000, LENGTH = 0x1000
}
SECTIONS
{
.text : { *(.text*) } > rom
.rodata : { *(.rodata*) } > rom
.bss : { *(.bss*) } > ram
}
build
arm-linux-gnueabi-gcc -Wall -O2 -ffreestanding -mcpu=cortex-m0 -mthumb -c main.c -o main.o
arm-linux-gnueabi-gcc -Wall -O2 -ffreestanding -mcpu=cortex-m0 -mthumb -c fun.c -o fun.o
arm-linux-gnueabi-ld -nostdlib -nostartfiles -T flash.ld flash.o main.o fun.o -o main.elf
arm-linux-gnueabi-objdump -D main.elf > main.list
arm-linux-gnueabi-objcopy -O binary main.elf main.bin
Your example program is dead code as written if optimized it would not generate an add instruction it would instead be:
main:
mov r0,#3
bx lr
there are other solutions but hiding the constants from the optimization
08000000 <_start>:
8000000: 20001000 andcs r1, r0, r0
8000004: 08000009 stmdaeq r0, {r0, r3}
08000008 <reset>:
8000008: f000 f802 bl 8000010 <main>
800000c: e7fe b.n 800000c <reset+0x4>
...
08000010 <main>:
8000010: b510 push {r4, lr}
8000012: 2102 movs r1, #2
8000014: 2001 movs r0, #1
8000016: f000 f801 bl 800001c <fun>
800001a: bd10 pop {r4, pc}
0800001c <fun>:
800001c: 1840 adds r0, r0, r1
800001e: 4770 bx lr
and the add happens. r0 gets the 1, r1 the 2, then the add turns r0 into the 3. So copy and run
> reg
===== arm v7m registers
(0) r0 (/32): 0x00000003
(1) r1 (/32): 0x00000002
(2) r2 (/32): 0x00000000
Last example
adding this to flash.s
.globl dummy
.thumb_func
dummy:
bx lr
and using this main.c
void dummy ( unsigned int );
int main ( void )
{
unsigned int ra;
for(ra=0;;ra++) dummy(ra);
}
Disassembly of section .text:
08000000 <_start>:
8000000: 20001000
8000004: 08000009
08000008 <reset>:
8000008: f000 f802 bl 8000010 <main>
800000c: e7fe b.n 800000c <reset+0x4>
0800000e <dummy>:
800000e: 4770 bx lr
08000010 <main>:
8000010: b510 push {r4, lr}
8000012: 2400 movs r4, #0
8000014: 0020 movs r0, r4
8000016: f7ff fffa bl 800000e <dummy>
800001a: 3401 adds r4, #1
800001c: e7fa b.n 8000014 <main+0x4>
800001e: 46c0 nop ; (mov r8, r8)
so both r4 and r0 will contain the count value, if we stop and start and stop we can see those count
> halt
target halted due to debug-request, current mode: Thread
xPSR: 0x01000000 pc: 0x0800000e msp: 0x20000ff8
> reg r0
r0 (/32): 0x0088B275
> resume
> halt
target halted due to debug-request, current mode: Thread
xPSR: 0x01000000 pc: 0x0800000e msp: 0x20000ff8
> reg r0
r0 (/32): 0x009B0F27
> reg r4
r4 (/32): 0x009B0F27
>
You can then move on to blinking leds, getting the uart initialized and so on.
Here is where you understand that baremetal programming is 99% reading and research, less than 1% of your time is spent on the actual application. You do lots of reading and write some amount of throwaway code figuring out what the manual really meant. You also find that most of the work has to do with the peripherals and not the architecture/instruction set. You spend more time dealing with configuring peripheral registers than dealing with configuring the processor if at all.
So there are lots of nucleo boards that support this "mbed" thing which implies that you can do the copy the bin file thing. There are nxp based ones as well as the first mbeds were nxp based. (nxp is another company that sells cortex-m based chips). If you invest in a nucleo board like the one mentioned above the debug end of the board can be used to debug other cortex-m based mcus even from other vendors, or for a few bucks you can get an swd based debugger or generic ftdi breakout that supports mpsse (adafruit has one for $15). If you are willing to gamble on ebay you can get a usb uart board for a couple of bucks and an swd debugger for five bucks. Or spend around $15 for each through amazon, adafruit or sparkfun, things that you will want in your toolbox.
Another path that is also arm based is the raspberry pi zero and a simple program on that is:
mov r0,#1
mov r1,#2
add r2,r1,r0
b .
no vector table.
Disassembly of section .text:
00000000 <.text>:
0: e3a00001 mov r0, #1
4: e3a01002 mov r1, #2
8: e0812000 add r2, r1, r0
c: eafffffe b c <.text+0xc>
you then copy the .bin file to an sd card and name it kernel.img. along with bootcode.bin and start.elf that you get from the raspberry pi folks github projects. insert the sd card into the board and power it on.
you can do jtag debugging using openocd but you will need hardware and you cant debug the above program. The chip is gpu centric the exposed jtag is the gpu, to connect to the arm jtag debugger you need to re-configure some gpio pins to expose the jtag which means some extra arm code that configures those gpio pins, then the right ftdi breakout or other board and four jumper wires for the basic jtag signals and you can use openocd to connect to the processor and stop it and such. Since it runs from ram, once stopped then you could load a program
load_image main.elf
resume 0x00000000
then halt load resume, repeat until you hang some peripheral or figure your program out, occasionally needing to reset to put the chip in a known state.
The other pis are much more complicated, you dont want to go there with baremetal for a long time. The good thing about the pi's though is an excellent baremetal forum at the site with lots of folks with deep understanding of the arm core and the chips plus example code of how to do various things including the magic you will need if you want to dabble into the multi-core armv7 or armv8 cores on that platform.
There is a risc-v based board for $9 on amazon, that has a similar fake flash based loading scheme risc-v is a bit harder to get started on than arm/thumb. so I wouldnt start here, and I dont remember what debug if anything is available on this board, so you somewhat blindly aim for blinking the led then later uart, then you can start using those to debug.
You can certainly buy arduino boards and ditch the arduino sandbox and write programs and load them, and do similar things as above. There is an avr instruction set simulator you can use. The harvard-ish nature of the instruction set and some other things I wouldnt start there, i migth go there later after some other instruciton set.
msp430 is a good simple starting point clearly inspired by the pdp11/lsi11 which is one of the better first instruction sets. Boards used to be a few bucks but now are probably more like $10 to $20 and can at least program the chip and blink leds for just the cost of the board with the right software. If you want to do uart, etc you might need a usb uart board for a few to 15 bucks.
opencores has some cores you can sim with tools like verilator or icarus, but you may end up spending a lot of time getting that all working esp if you have no experience with these tools or these hardware languages. But take the amber arm2/3 core or one of the others, figure out how to feed the core, they you can get a full experience of seeing the code execute in a core, watch the instructions get fetched, decoded, etc, etc...Once you develop code for a chip in a sim it is difficult when the silicon arrives because you now have no visibility into what is going on. (granted the silicon runs orders of magnitude faster).
Another approach is to write your own instruction set simulator. msp430 you can probably bang out in a long afternoon. minimal thumb maybe a full day. then learn to master the toolchain (just use gnu binutils and gcc) to create binaries as above, then feed your sim these programs and make/watch them run. Writing a simulator and/or disassembler you will learn the instruction set nuances better than some professionals that have been using that instruction set for years since so few really dig into the nuances. Using a debugged toolchain ideally means the code is good and things that are not always well documented like pc-relative addressing or branching is generated in the machine code and you can figure out how to decode that.
End of the day, dont limit yourself to one platform, the approach above has suggestions at approaches that will hopefully maximize your initial success, limit your frustration and desire to give up and never try again. With some marks in the win column that drives motivation to keep on and grind through the failures to get at more wins. x86 is a bit of a beast, you can certainly work your way up to it, but why? You are not going to boot one from scratch, you can certainly start from where something like windows or linux boots from media and go from there. An x86 box has more non-x86 processors than x86 processors, there are more arm processors in that box than x86s, and/or a smattering of others, 8051s, z80s, etc. x86 is rapidly becoming limited to servers and for now ARM's for everything else (phones and tablets and soon to be laptops). It is interesting to see what a full blown CISC looks like and how it works, and personally I would start with an 8088/86 emulator and have a manageable and safe experience with a high chance of success. And then read sites like stackoverflow and intel docs to see how the current instruction set evolved over time as well as the gory details of all the protection systems, etc.
I highly recommend you instead start this journey with microcontrollers and/or simulators/emulators. Inexpensive, folks still develop at this level, if one doesnt work for you or you brick it oh well buy another or buy a different one for another 10 bucks. Depending on the journey you want to take you can then use free tools like kicad, and buy some $1 mcu parts, a few dollar pcb, solder the chip on yourself and open up even more chips to play with that dont have cheap eval boards.
EDIT
rereading the quesiton, "seeing" output other than the debugger visibility above, for that nucleo board
flash.s
.cpu cortex-m0
.thumb
.thumb_func
.global _start
_start:
.word 0x20001000
.word reset
.thumb_func
reset:
bl notmain
b hang
.thumb_func
hang: b .
.thumb_func
.globl dummy
dummy:
bx lr
notmain.c
void dummy ( unsigned int );
#define RCCBASE 0x40023800
#define RCC_AHB1ENR (*((volatile unsigned int *) (RCCBASE+0x30) ))
#define GPIOABASE 0x40020000
#define GPIOA_MODER (*((volatile unsigned int *) (GPIOABASE+0x00) ))
#define GPIOA_BSRR (*((volatile unsigned int *) (GPIOABASE+0x18) ))
//PA5
int notmain ( void )
{
unsigned int ra;
unsigned int rx;
ra=RCC_AHB1ENR;
ra|=1<<0; //enable GPIOA
RCC_AHB1ENR=ra;
ra=GPIOA_MODER;
ra&=~(3<<(5<<1)); //PA5
ra|= (1<<(5<<1)); //PA5
GPIOA_MODER=ra;
for(rx=0;;rx++)
{
GPIOA_BSRR=((1<<5)<< 0);
for(ra=0;ra<800000;ra++) dummy(ra);
GPIOA_BSRR=((1<<5)<<16);
for(ra=0;ra<800000;ra++) dummy(ra);
}
return(0);
}
flash.ld
MEMORY
{
rom : ORIGIN = 0x08000000, LENGTH = 0x1000
ram : ORIGIN = 0x20000000, LENGTH = 0x1000
}
SECTIONS
{
.text : { *(.text*) } > rom
.rodata : { *(.rodata*) } > rom
.bss : { *(.bss*) } > ram
}
build
arm-linux-gnueabi-as --warn --fatal-warnings -mcpu=cortex-m0 flash.s -o flash.o
arm-linux-gnueabi-gcc -Wall -O2 -ffreestanding -mcpu=cortex-m0 -mthumb -c notmain.c -o notmain.o
arm-linux-gnueabi-ld -nostdlib -nostartfiles -T flash.ld flash.o notmain.o -o notmain.elf
arm-linux-gnueabi-objdump -D notmain.elf > notmain.list
arm-linux-gnueabi-objcopy -O binary notmain.elf notmain.bin
copy it over and the led will blink.
Disassembly of section .text:
08000000 <_start>:
8000000: 20001000
8000004: 08000009
08000008 <reset>:
8000008: f000 f804 bl 8000014 <notmain>
800000c: e7ff b.n 800000e <hang>
0800000e <hang>:
800000e: e7fe b.n 800000e <hang>
08000010 <dummy>:
8000010: 4770 bx lr
...
08000014 <notmain>:
8000014: 2101 movs r1, #1
8000016: b5f0 push {r4, r5, r6, r7, lr}
8000018: 46c6 mov lr, r8
800001a: 4a12 ldr r2, [pc, #72] ; (8000064 <notmain+0x50>)
800001c: b500 push {lr}
800001e: 6813 ldr r3, [r2, #0]
8000020: 2780 movs r7, #128 ; 0x80
8000022: 430b orrs r3, r1
8000024: 4910 ldr r1, [pc, #64] ; (8000068 <notmain+0x54>)
8000026: 6013 str r3, [r2, #0]
8000028: 680b ldr r3, [r1, #0]
800002a: 4a10 ldr r2, [pc, #64] ; (800006c <notmain+0x58>)
800002c: 4e10 ldr r6, [pc, #64] ; (8000070 <notmain+0x5c>)
800002e: 401a ands r2, r3
8000030: 2380 movs r3, #128 ; 0x80
8000032: 00db lsls r3, r3, #3
8000034: 4313 orrs r3, r2
8000036: 600b str r3, [r1, #0]
8000038: 2320 movs r3, #32
800003a: 4698 mov r8, r3
800003c: 4d0d ldr r5, [pc, #52] ; (8000074 <notmain+0x60>)
800003e: 03bf lsls r7, r7, #14
8000040: 4643 mov r3, r8
8000042: 2400 movs r4, #0
8000044: 6033 str r3, [r6, #0]
8000046: 0020 movs r0, r4
8000048: 3401 adds r4, #1
800004a: f7ff ffe1 bl 8000010 <dummy>
800004e: 42ac cmp r4, r5
8000050: d1f9 bne.n 8000046 <notmain+0x32>
8000052: 2400 movs r4, #0
8000054: 6037 str r7, [r6, #0]
8000056: 0020 movs r0, r4
8000058: 3401 adds r4, #1
800005a: f7ff ffd9 bl 8000010 <dummy>
800005e: 42ac cmp r4, r5
8000060: d1f9 bne.n 8000056 <notmain+0x42>
8000062: e7ed b.n 8000040 <notmain+0x2c>
8000064: 40023830
8000068: 40020000
800006c: fffff3ff
8000070: 40020018
8000074: 000c3500